Kizuki's one rule is to never misinform you. The model never writes facts in its own words. It picks sentence labels, question kinds, and words you wrote, and plain code does the rest. When sources disagree, your corrections come first, then your material, and the model's own knowledge never counts.
A prompt can ask the model to behave. Only code can check that it did, so each place the model is used has a guard in code.
| The model... | It replies with | The guard |
|---|---|---|
| proposes concepts | a name and sentence labels (conceptReplySchema) | validateConceptReply |
| points at sentences it cannot read | sentence labels | the same guard; the question is fixed words |
| proposes prerequisite links | concept labels like C3 (linkReplySchema) |
validateLinkReply |
| chooses teach-back questions | a kind, a sentence label, and your words (teachReplySchema) | validateTeachReply |
| adds to what you missed | sentence labels (missesReplySchema) | validateMisses |
Search, the review schedule, storage, and the "barely used" miss check use no model.
The model sees every sentence with a label such as [S3] (numberSentences) and answers with labels. lookupSentence looks up the exact sentence, so the model has no way to make up a quote. The word-for-word check (quoteMatches) still runs on every quote as a second guard. A label that points nowhere drops the item, and the page counts what was dropped.
renderQuestion writes every teach-back question from a fixed template around the exact sentence, its location, and your words. A question about a sentence Kizuki cannot read is always UNCLEAR_QUESTION: "Kizuki is not sure how to read this sentence. What does it mean?" The model only points at the sentence.
A concept name is the one place the model writes words that Kizuki shows. validateConceptReply keeps a name only when:
Concepts for real headings do not come from the model at all. headingConcepts names each after its heading and backs it with the first sentences of its section.
The model must reply in a fixed JSON shape. With Ollama or OpenAI the server enforces the shape. With MLX, which ignores shapes, askWithShapeInPrompt puts the shape in the instructions, checks the reply with parseJsonReply, and asks once more after a broken reply. A second broken reply is an error. Error messages never include the model's own words, because Kizuki saves errors and shows them on the page.
The guards have plain tests with no model (lib/*.test.ts). The model tests in evals/ (run with npm run eval) check real small models against hand-written answer keys with planted mistakes.