Files
Hankan/app/src/domain/evidence.ts
MechaCat02 a1c86d9550 feat(tutor): the turn enforced — retries, evidence, the 다지기 checklist, earned progress
"The client enforces; the prompt only explains." Every rule the artifact's
tutor was merely asked to follow, it broke: it certified words on one
correct answer, scored a unit before anything was answered, used a word
from three phases ahead, answered in Korean, and invented spelling
diagnoses. The reworked app fixed each by making the client refuse. This
ports those refusals; domain/turn.ts holds the turn, testable without React.

The gate. A reply is scanned before he sees it — the side of the exercise
he must decode, through the one resolver, and its prose for Korean. A
refused draft is never stored, shown or applied: the tutor is asked again
and told exactly why. After two retries the reply is shown with its words
flagged, and the next turn names them. (The artifact's follow-up told the
tutor it could declare such a word in ::words; that contradicts the gate
and is left out.)

Marking. ::result feeds recall evidence per word. lib/srs.js is looser
than PORT.md, so the call site tightens it: one outcome per word per round,
and "learned" also needs five rounds between the first and last CORRECT
answer — lib alone counted a wrong answer as the start of the span. A
lookup is never recall. What he mistook a word for is kept. The schedule
takes at most one good grade a day from marking; in the artifact five good
rounds in one afternoon made a word "secure" by interval alone.

Phase reviews. The client holds the 다지기 checklist — each unit's rule and
every word the phase introduced, 132 items for Phase 1 — worked in batches
of ten. ::confirmed ticks a rule on the tutor's word but a word only on
evidence; "-item" puts one back; anything off the list is ignored.

Progress is earned: ignored until the unit has an answer, +25 at most per
message, a fall honoured in full, and the next unit only at 85% with three
answers — plus, in a review, nothing open. advanceUnit() enforces it too,
not only the banner.

The prompt gains a per-round tail after the shipped prompt — the practice
set (scored on the evidence, round-robin by word class, each word with the
words one letter away), the checklist, retry notes — sent as a second,
uncached system block so the stable prefix still caches.

Also: recall answers carry the letter-level jamo comparison (kept out of
his own bubble, since it is written to the model); match chips are keyed by
pair index, the bug PORT.md names; and the stand-in tutor exercises every
path offline — recall, ::result, ::confirmed, progress only after answers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:09:51 +02:00

230 lines
7.3 KiB
TypeScript

/* Recall evidence — whether he KNOWS a word, kept apart from when to show it.
The artifact's tutor kept certifying words on a single correct answer, so
the client keeps the count and the tutor's marking only feeds it. What
counts as learned is PORT.md's bar: three corrects, in three separate
rounds, at least five rounds between the first correct and the last, none
of them after a lookup.
lib/srs.js holds that model, a little looser than PORT.md words it: it
credits several corrects inside one round, and measures the span from
the first outcome of any kind. Both are tightened here, at the call site —
lib/ ships unchanged:
· a message's marking is one round, and each word gets one outcome in
it (the first time it is listed);
· evidence keeps the rounds of its first and last CORRECT answer, and
learned requires lib's isLearned() AND that span. */
import type { Db } from "../db/types.js";
import type { ResultRow } from "@lib/blocks.js";
import {
AGAIN,
GOOD,
LEARNED_SPAN,
grade,
isLearned as libLearned,
markKnown,
newCard,
newEvidence,
noteOutcome,
type Card,
type Evidence,
} from "@lib/srs.js";
import {
editCard,
editConfusion,
editEvidence,
editMeta,
type EvidenceRow,
} from "../db/writes.js";
export const ROUND_KEY = "learner.round";
/** Sources a marked word may grade a card from — the review deck's. */
const REVIEWABLE = "('curated', 'grammar', 'sfx', 'curriculum', 'custom')";
export async function currentRound(db: Db): Promise<number> {
const v = (await db.get<{ v: string }>("SELECT v FROM meta WHERE k = ?", [ROUND_KEY]))?.v;
const n = Number(v ?? 0);
return Number.isFinite(n) ? n : 0;
}
export const emptyEvidence = (word: string): EvidenceRow => ({
word,
ok: 0,
wrong: 0,
lookups: 0,
streak: 0,
first_round: 0,
last_round: 0,
last_seen: 0,
rounds: 0,
first_ok_round: 0,
last_ok_round: 0,
});
const toLib = (e: EvidenceRow): Evidence => ({
ok: e.ok,
wrong: e.wrong,
lookups: e.lookups,
streak: e.streak,
firstRound: e.first_round,
lastRound: e.last_round,
lastSeen: e.last_seen,
rounds: e.rounds,
});
/** lib's bar, and the span between the first and last CORRECT answer. */
export function isLearned(e: EvidenceRow): boolean {
return libLearned(toLib(e)) && e.first_ok_round > 0 && e.last_ok_round - e.first_ok_round >= LEARNED_SPAN;
}
/** Evidence for these words, or for every word with any. */
export async function readEvidence(db: Db, words?: string[]): Promise<Map<string, EvidenceRow>> {
const rows = words
? words.length
? await db.all<EvidenceRow>(
`SELECT * FROM evidence WHERE word IN (${words.map(() => "?").join(",")})`,
words,
)
: []
: await db.all<EvidenceRow>("SELECT * FROM evidence");
return new Map(rows.map((r) => [r.word, r]));
}
/**
* The card a word's marking grades: the curriculum's own card for a roadmap
* word, else the most curated reviewable lemma with that headword.
*/
export async function cardLemmaFor(db: Db, word: string): Promise<number | null> {
const row = await db.get<{ id: number }>(
`SELECT id FROM lemma WHERE headword = ? AND source IN ${REVIEWABLE}
ORDER BY unit_id IS NULL,
CASE source WHEN 'curated' THEN 0 WHEN 'curriculum' THEN 1 WHEN 'grammar' THEN 2
WHEN 'sfx' THEN 3 ELSE 4 END
LIMIT 1`,
[word],
);
return row?.id ?? null;
}
export interface WordCard {
lemmaId: number;
pos: string;
gloss: string;
}
/** cardLemmaFor() for many words at once, with the lemma's pos and gloss. */
export async function cardsFor(db: Db, words: string[]): Promise<Map<string, WordCard>> {
const out = new Map<string, WordCard>();
for (let i = 0; i < words.length; i += 900) {
const batch = words.slice(i, i + 900);
const rows = await db.all<{ id: number; headword: string; pos: string; gloss_en: string }>(
`SELECT id, headword, pos, gloss_en FROM lemma
WHERE headword IN (${batch.map(() => "?").join(",")}) AND source IN ${REVIEWABLE}
ORDER BY unit_id IS NULL,
CASE source WHEN 'curated' THEN 0 WHEN 'curriculum' THEN 1 WHEN 'grammar' THEN 2
WHEN 'sfx' THEN 3 ELSE 4 END`,
batch,
);
for (const r of rows) {
if (!out.has(r.headword)) out.set(r.headword, { lemmaId: r.id, pos: r.pos, gloss: r.gloss_en });
}
}
return out;
}
async function readCard(db: Db, lemmaId: number): Promise<Card | null> {
const row = await db.get<Card>(
"SELECT state, ease, interval, due, reps, lapses FROM card WHERE lemma_id = ?",
[lemmaId],
);
return row ?? null;
}
export interface ResultOutcome {
round: number;
/** Words whose evidence was updated. */
recorded: string[];
/** Items that named no studiable word — dropped, as the artifact does. */
ignored: string[];
/** Words that crossed the learned bar with this message. */
learned: string[];
}
/**
* Apply one message's ::result block.
*
* `lookups` are the words he looked up while answering — a correct answer
* after a lookup is not recall: it resets the streak and earns nothing.
* The schedule takes the grade too, but a GOOD at most once per card per day:
* a card already graded today is no longer due, and in the artifact five
* good rounds in one afternoon made a word "secure" by interval alone.
*/
export async function applyResults(
db: Db,
results: ResultRow[] | null,
lookups: Iterable<string>,
today: number,
): Promise<ResultOutcome | null> {
if (!results?.length) return null;
const round = (await currentRound(db)) + 1;
await editMeta(db, ROUND_KEY, String(round));
const looked = new Set(lookups);
const seen = new Set<string>();
const out: ResultOutcome = { round, recorded: [], ignored: [], learned: [] };
for (const r of results) {
const word = r.item.trim();
if (!word || seen.has(word)) continue; // one outcome per word per round
seen.add(word);
const lemmaId = await cardLemmaFor(db, word);
if (lemmaId === null) {
out.ignored.push(word);
continue;
}
const before = (await readEvidence(db, [word])).get(word) ?? emptyEvidence(word);
const lookedUp = looked.has(word);
const noted = noteOutcome(toLib(before), r.ok ? "ok" : "wrong", round, lookedUp);
const recall = r.ok && !lookedUp;
const after: EvidenceRow = {
word,
ok: noted.ok,
wrong: noted.wrong,
lookups: noted.lookups,
streak: noted.streak,
first_round: noted.firstRound,
last_round: noted.lastRound,
last_seen: noted.lastSeen,
rounds: noted.rounds,
first_ok_round: recall && !before.first_ok_round ? round : before.first_ok_round,
last_ok_round: recall ? round : before.last_ok_round,
};
await editEvidence(db, after);
out.recorded.push(word);
if (!isLearned(before) && isLearned(after)) out.learned.push(word);
if (!r.ok && r.mistakenFor) await editConfusion(db, word, r.mistakenFor);
const card = await readCard(db, lemmaId);
if (recall) {
if (!card || card.due <= today) {
await editCard(db, lemmaId, isLearned(after) ? markKnown(today) : grade(card ?? newCard(), GOOD, today));
}
} else if (!r.ok) {
await editCard(db, lemmaId, grade(card ?? newCard(), AGAIN, today));
}
}
return out;
}
/** A fresh record, for callers that need lib's shape. */
export { newEvidence };