xavier-ramirez.com
← Alignment & the content graph

Your own lesson pipeline · 03 · 28 Aug 2026 · 12 min read

Appropriateness as a computed property

One rubric, two runtimes — and never let the model grade its own homework.

The tempting move is a boolean. One column, set by a classifier at generation time, checked before publish. It reads beautifully for a month. Then a teacher edits a block and the flag stays true, someone asks what “safe” meant in March and the rubric has been rewritten twice since, and your evaluation harness reports 94% pass while teachers rewrite six lessons in ten. This post builds appropriateness as a set of derived, versioned properties over immutable blocks.
01 · Spine02 · Alignment03 · Appropriateness04 · Guardrails & deletion05 · Publishing & fidelity06 · Efficacy

This post deepens the intelligence stage: turning generated blocks into properties a gate and a human can both act on.

Worked exampleThis series uses a K–8 supplemental curriculum publisher as its running example — standards frameworks from three states, generated interactive lessons, the teachers who edit them, and the learning-management courses in two districts the platform publishes into. The pipeline itself is domain-agnostic; the content just happens to belong to third graders.

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.

four measurements, one wordrubric g3.2026-08
The block
The district

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.
equivalentindividually
holdsimplify_language

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.

sql/block_properties.sql
-- Append-only, keyed by (block, rubric version). A block is immutable, so a property
-- computed under a given rubric version is true forever and never needs updating.
CREATE TABLE block_properties (
  block_hash        text        NOT NULL,
  rubric_version    text        NOT NULL,   -- "g3.2026-08"
  -- readability
  fk_grade_level    real        NOT NULL,   -- a proxy for decoding load, and known as one
  sentence_len_p90  real        NOT NULL,
  -- vocabulary
  tier2_ratio       real        NOT NULL,   -- academic vocabulary as a share of content words
  offlist_terms     text[]      NOT NULL,   -- words outside the grade-band list, for a human to see
  -- conceptual load
  prereq_depth      int         NOT NULL,   -- from post 2's graph, not from the text
  unexplained_terms text[]      NOT NULL,   -- terms used before they are introduced in this lesson
  -- sensitivity
  topic_flags       text[]      NOT NULL,   -- classifier labels, verbatim, never rewritten
  classifier_version text       NOT NULL,
  computed_at       timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (block_hash, rubric_version)
);
Why not just ask a model whether it is appropriate for third grade?
Because you cannot tune, explain or defend the answer. A teacher who disagrees with a hold needs to see which property failed and by how much, or the gate is a black box telling a professional she is wrong. A district negotiating thresholds needs numbers to negotiate over. And an incident review needs to distinguish “the rubric was too loose” from “the classifier missed it”, which is one question if you stored four properties and no question at all if you stored a verdict. Use models inside individual properties, absolutely — sensitivity classification is a model, and so is “terms used before they are introduced”. But the rubric is arithmetic over properties, because arithmetic is auditable and a judgement is not.

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.

rubrics/g3.2026-08.yaml
version: g3.2026-08
grade_band: "3"
supersedes: g3.2026-02
thresholds:
  fk_grade_level:     { max: 4.5 }        # a proxy for decoding load, not a measure of quality
  sentence_len_p90:   { max: 16 }
  tier2_ratio:        { min: 0.04, max: 0.14 }
  prereq_depth:       { max: 2 }          # relative to the objective, from the content graph
  offlist_terms:      { max_count: 3 }
  unexplained_terms:  { max_count: 0 }
decisions:
  # First match wins. Every rule names the human action it implies.
  - when: { topic_flags: { any_of: [violence, self_harm, sexual, hate] } }
    result: hold
    action: escalate_to_safety_reviewer
  - when: { unexplained_terms: { count_gt: 0 } }
    result: hold
    action: regenerate_block
  - when: { fk_grade_level: { gt: 4.5 } }
    result: hold
    action: simplify_language
  - when: { offlist_terms: { count_gt: 3 } }
    result: flag                          # visible to the teacher, not blocking
    action: review_vocabulary
  - else: pass
district_overrides:
  riverbend: { fk_grade_level: { max: 3.2 } }    # reads roughly two years below band
src/rubric/evaluate.ts
import { loadRubric } from "./load.js";       // reads rubrics/*.yaml — the ONLY source of numbers
import type { BlockProperties, GateDecision } from "./types.js";

export function evaluate(
  props: BlockProperties, rubricVersion: string, districtId: string,
): GateDecision {
  const rubric = loadRubric(rubricVersion, districtId);   // applies district_overrides
  for (const rule of rubric.decisions) {
    if (rule.else || matches(rule.when, props)) {
      return {
        result: rule.result ?? "pass",
        action: rule.action ?? null,
        rubricVersion,          // stamped, always — this is what makes a rubric change auditable
        failedOn: rule.else ? null : Object.keys(rule.when),
      };
    }
  }
  throw new Error(`rubric ${rubricVersion} has no terminal rule`);   // never default to pass
}
workers/eval/rubric.py
"""The Python side loads the same YAML. No thresholds live in this file, by design."""
from lessonpipe.rubric import load_rubric, matches
from lessonpipe.contracts import BlockProperties, GateDecision


def evaluate(props: BlockProperties, rubric_version: str, district_id: str) -> GateDecision:
    rubric = load_rubric(rubric_version, district_id)
    for rule in rubric.decisions:
        if rule.is_else or matches(rule.when, props):
            return GateDecision(
                result=rule.result or "pass",
                action=rule.action,
                rubric_version=rubric_version,
                failed_on=None if rule.is_else else sorted(rule.when),
            )
    raise ValueError(f"rubric {rubric_version} has no terminal rule")
tests/conformance/README
Each fixture is a block plus the decision BOTH runtimes must produce.
CI runs the gate and the evaluation harness over the same directory and diffs.

  fixtures/
    g3_passing_explanation.json
    g3_offlist_vocabulary.json          -> flag / review_vocabulary
    g3_unexplained_term.json            -> hold / regenerate_block
    g3_sensitive_topic.json             -> hold / escalate_to_safety_reviewer
    g3_riverbend_override.json          -> hold under riverbend, pass under oakhurst

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.

one corpus, three graders
How are we grading it?

↳ Nothing about the blocks changes between these three. Only the judge and the population do.

Population · all 1,000 generated blocks
This measures self-consistency, not quality. The failure modes the judge and the generator share — a confident explanation that skips the conceptual step, a worked example that is arithmetically right and pedagogically backwards — are exactly the ones it cannot see, because it would have written them.

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.

workers/eval/dataset.py
"""Build an eval set that cannot flatter itself.

Three rules, each undoing one of the failure modes above:
  1. Population is every block generated in the window — held and abandoned included.
  2. Properties are read at the rubric version in force when the block was generated.
  3. The label is the teacher's edit distance, not a model's opinion.
"""
QUERY = """
SELECT b.block_hash,
       b.kind,
       p.fk_grade_level, p.tier2_ratio, p.prereq_depth, p.topic_flags,
       g.result                                    AS gate_result,
       -- The label: did a human change this block, and how much?
       coalesce(e.edit_distance, 0)                AS edit_distance,
       coalesce(e.was_deleted, false)              AS was_deleted
  FROM blocks AS b
  -- INNER JOIN on the rubric version in force at generation time. Never "the current rubric".
  JOIN rubric_windows AS w
    ON  b.created_at >= w.effective_from
    AND b.created_at <  coalesce(w.effective_to, 'infinity'::timestamptz)
    AND w.district_id = b.district_id
  JOIN block_properties AS p
    ON  p.block_hash = b.block_hash
    AND p.rubric_version = w.rubric_version
  JOIN gate_decisions AS g
    ON  g.block_hash = b.block_hash
    AND g.rubric_version = w.rubric_version
  -- LEFT, not INNER: blocks nobody ever reviewed are the negatives you most need.
  LEFT JOIN teacher_edits AS e
    ON  e.block_hash = b.block_hash
 WHERE b.district_id = %(district_id)s
   AND b.created_at BETWEEN %(start)s AND %(end)s
"""

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.

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.

Run it yourself · open-lesson-pipeline @ v3-rubric
Score the same corpus two ways — with a same-family judge and with teacher-edit labels — and watch the two numbers disagree.
terminal
git clone https://github.com/xaviramirezcom/open-lesson-pipeline
cd open-lesson-pipeline
git checkout v3-rubric
pnpm install && uv sync
pnpm lesson up
pnpm lesson seed --with-teacher-edits    # synthetic edits from 30 simulated teachers

pnpm lesson gate --district riverbend --rubric g3.2026-08
pnpm lesson eval --judge same-family                      # the flattering number
pnpm lesson eval --judge teacher-edits                    # the honest one
pnpm lesson eval --judge teacher-edits --survivors-only   # the honest one, biased on purpose
pnpm lesson rubric diff g3.2026-02 g3.2026-08 --recompute
The same-family judge reports 94% acceptable. The teacher-edit label reports that 61% of blocks were changed and 9% deleted outright. The third run is the instructive one: restricted to blocks a teacher actually opened, the edit rate falls to 38% — not because the content is better, but because the population is. Then the rubric diff recomputes properties for every block under both versions and prints how many decisions flip, which is what a rubric change actually costs: 1,204 blocks reclassified, 87 previously published blocks that would not pass today. That last number is a work queue, and no boolean column could have produced it.

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

A generator upgrade ships with the offline quality score up from 0.88 to 0.94, and the team rolls it out to both districts. Six weeks later, teacher edit rate is unchanged, but the deletion rate has tripled and support is fielding complaints that lessons “feel padded”. No job failed, no gate decision changed, and the rubric is the same version it was. What is the most likely cause, how do you confirm it in an afternoon, and what would have caught it in CI?
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.

Next in the series · 04
Guardrails, tenancy & deletion

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