Properties
“Age-appropriate” is four measurements wearing one word
Post 1 left you with immutable, content-addressed blocks. The appropriateness layer does not store a verdict about a block; it derives properties from it, records them with the rubric version that produced them, and lets the gate decide. Drive it below — the same block, two districts.
↳ reads roughly two years below band · ceiling 3.2
explanationMultiplication describes a situation in which several equivalent quantities are combined, and the total can be found without counting each item individually.
Held on fk_grade_level — reading level 4.1 against this district's ceiling of 3.2. First match wins, so this is the only rule that fired, and it names the action a human is supposed to take.
Same block, other district: pass. The measurements belong to the block and the thresholds belong to the district — which is a distinction a single kid_safe boolean cannot make.
- The four things are independent and fail independently. Readability (can she decode the sentences), vocabulary (does she know these words yet), conceptual load (does this assume a prerequisite she has not met), and sensitivity (is the subject matter appropriate for this age). A block can pass three and fail one, and which one it failed decides whether a human rewrites a sentence or the whole block goes back.
- A verdict has no history. “Was this block appropriate under the rubric we shipped in March” is unanswerable once you have overwritten the flag — which, as the evaluation section shows, makes honest measurement impossible.
- A boolean cannot be tuned per district. Same content, same rubric, different thresholds — and the thresholds belong to the district while the measurements belong to the block.
- Recomputation is the fix for everything. Derived properties can be recomputed for every block ever generated when the rubric changes. A stamped boolean cannot be un-stamped.
The rubric
One definition, two runtimes
The gate runs in the publish path. The evaluation harness runs in CI and in research notebooks, in a different language. Those are genuinely different runtimes — and the moment you write the rubric twice, they start to drift. Someone tightens a threshold in the gate, or fixes a null-handling bug in the harness, and only one side gets it. Nothing errors. The evaluation just quietly stops describing production.
- The rubric is data, not code. A versioned document with thresholds and a decision table. Both runtimes load the same file.
- Neither runtime may hard-code a threshold. If a number appears in a source file, it is a bug — and it is the kind of bug a linter can find.
- Parity is a conformance suite, not a convention. A directory of blocks with expected decisions, run by both runtimes in CI. Adding a threshold without adding a fixture fails the build.
- The rubric version is stamped on every decision. A held block records which rubric held it, which is what makes a rubric change a migration you can reason about rather than a silent reclassification of your entire corpus.
That conformance directory is the entire discipline in one folder. It fails loudly the day someone edits one runtime, which is the only moment the divergence is cheap to fix.
Evaluation
The offline number that means nothing
You want to know whether your generator produces good third-grade content. You take a thousand generated blocks, ask a frontier model to score them against a quality rubric, and get 94%. Everyone is delighted. In production, teachers rewrite something in six lessons out of ten. Here is the same corpus, graded three ways.
↳ Nothing about the blocks changes between these three. Only the judge and the population do.
And the quieter fourth failure, which hides behind the other three: scoring historical blocks against the current rubric. Recompute properties for every block under both versions and a rubric revision turns out to reclassify 1,204 blocks and leave 87 already-published ones that would not pass today. That last figure is a work queue. No boolean column could have produced it.
Three separate mechanisms are hiding in that gap. The judge shares the generator’s blind spots — score content from one model family with a judge from the same family and you have measured self-consistency. The rubric was probably derived from the outputs, which fits the exam to the student: the criteria that would have failed it never got written. And the eval set is made of survivors — build a benchmark from lessons teachers published and your negatives are near-misses rather than the content that was held, abandoned or never generated because the request failed.
- The label has to come from outside the model’s world. Teacher edits are the label. They are free, continuous, and made by the only people whose judgement the product is actually claiming to augment.
- Judge with a different family, or with humans, or both. And measure the judge against the human sample periodically, because a judge is a model and it drifts too.
- Sample the population you deploy on, not the one you kept. Held blocks, abandoned drafts and failed requests all belong in the denominator.
- Join evaluation on the rubric version in force at the time. Always. It is one predicate, and skipping it lets a rubric change silently rewrite your history.
The outer join on teacher edits is the whole survivorship fix in one keyword. Make it an inner join — which is what you write if you are thinking “I need blocks with labels” — and your dataset silently becomes blocks a teacher opened, which is a biased sample of blocks that passed the gate, which is a biased sample of blocks the generator was good at. The population is defined by the window and the district, and nothing else is allowed to filter it.
Feedback
The teacher’s edit is the only label you did not have to pay for
A teacher rewriting a hook is annotating your training data. A teacher deleting an item is telling you the alignment was wrong. A teacher swapping an image is telling you something about a rights record, a reading level or a classroom you do not know about. All of it is free and continuous, and almost every product in this space throws it away by storing edits as an overwrite.
- Store the edit, not just the result. Which block was replaced, by what, by whom, and — where the interface can cheaply ask — why. A one-click reason chip on an edit is the highest-value component in the product.
- Edit distance is a graded signal, not a boolean. A punctuation fix and a full rewrite are both “edited”, and treating them the same throws away most of the information.
- Deletions are the strongest signal you have. They are the only unambiguous statement that the block should not have existed, and they are rare enough to review individually.
- Segment by teacher before you believe anything. One teacher who rewrites everything is a style preference; forty teachers who all rewrite the same block is a defect.
The trap is closing the loop too tightly. Fine-tuning directly on teacher edits sounds obviously right and drifts your generator toward whatever a small, self-selected group of early adopters prefers — usually more text, more scaffolding, and their own voice. Use edits as evaluation first and as training data second, deliberately, with a held-out set of teachers whose edits never enter the training corpus so you retain an uncontaminated measure. And note the governance constraint that arrives in the next post: teacher edits are staff data and manageable, but the moment student work enters this loop you are training on children, and that is a decision with a different shape entirely.
Teaching-grade reference implementation, not a production courseware platform. It reproduces the ideas and the queue/warehouse integration shape; bring your own model keys, curriculum data and LMS credentials. LMS adapters run against a local mock by default. MIT-licensed. View the repo →
Explain it back
Reveal a model answer
The offline score and the classroom disagree because the score is measuring something the classroom does not value. The most likely mechanism is that the upgrade made the generator more verbose — more scaffolding, more restatement, longer explanations — which a judge model reliably rewards and a third-grade teacher reliably deletes. Edit rate held steady because the blocks that were fine are still fine; deletions tripled because the new failure mode is whole blocks that should not exist, which is a deletion rather than a rewrite. The rubric did not catch it because none of its properties measure redundancy: the padding is on-grade, on-vocabulary, on-topic and conceptually sound. It is just unnecessary, and “unnecessary” was never a threshold.
Confirm it in an afternoon by joining deleted blocks to their properties and comparing distributions against retained ones. If deleted blocks are systematically longer, or systematically the second and third explanation within a lesson, you have it — and the fixture set to prove it writes itself from the twenty clearest examples.
CI catches it two ways, neither of which existed. First, the eval gate should have been the teacher-edit label rather than the judge score, or at minimum should have failed the release when the two diverged by more than a set margin — a judge score that improves while the human label does not is the signal, and it is available before rollout if you hold out a teacher cohort. Second, a release check on the distribution of generated output — blocks per lesson, words per block — treated as a regression when it shifts, because a generator that starts producing 40% more text has changed the product whether or not any single block got worse.
The bonus consequence outlasts the fix. Those six weeks of teacher edits are now in your feedback corpus, and they encode “delete the third explanation” as a strong signal. Train on them uncritically and the next generator learns to under-explain for the students who genuinely needed that scaffolding — the ones whose teachers were not the ones deleting.
Properties are only worth computing if the gate cannot be routed around, the tenancy holds, and a deletion reaches every copy — including the one it never can.
Close the side doors →