xavier-ramirez.com
← Publishing & fidelity

Your own lesson pipeline · 06 · 31 Aug 2026 · 12 min read

Efficacy & incrementality

Engagement counts what happened — efficacy asks whether it mattered.

The tempting version is a dashboard. Completion rate on one axis, time on task on the other, a mastery column sorted descending, and a renewal meeting that points at the green. It feels like measurement because it has numbers in it. But every row describes children who used the lesson, and none of them answers the only question a district is really asking, which is what would have happened without you.
01 · Spine02 · Alignment03 · Appropriateness04 · Guardrails & deletion05 · Publishing & fidelity06 · Efficacy

This post deepens the stage after publishing — the one that decides whether the five stages before it were worth running.

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.

The interval

Can this study see the thing you are hoping for?

Start at the end, because the arithmetic below decides whether the rest of the post is worth running. Three controls — how many classrooms you have, how alike the children in one room are, and whether your standard error knows they share a teacher.

can this study see it?the real effect is +0.18 SD
How alike one class is
Standard error

↳ Leave the pilot at 12 classrooms and switch to naive. Same children, same scores, opposite conclusions.

Children per arm
300
Design effect
4.6×
Effective sample
65
95% interval
-0.16 … 0.52
No detectable effect. The interval spans zero, so this study is equally consistent with the unit costing children learning — and the smallest effect it could ever have resolved is 0.49 SD.

The design floor is the smallest effect this arrangement could catch four times out of five. It is a property of the arithmetic rather than of the content, and you can compute it from the classrooms you actually have before a single child sees a lesson — which is the entire reason to compute it first.

That last control is not a modelling nicety. A naive standard error on clustered data is the single most common way an education efficacy claim gets published wrong, and it always errs toward a narrower interval — which means toward a more exciting finding.

The problem

Completion makes your best lesson the one the strongest students finished

Follow one child. She finishes the lesson, scores eight out of ten on the exit items, and spends nine minutes on task. Another child opens it, stalls on the second block, and closes the tab. Your dashboard now reports that students who complete the lesson score 80%, and a slide gets made about mastery. Nothing in that sentence is false and nothing in it is evidence, because the strongest predictor of finishing a lesson is being the kind of student who finishes lessons.

What engagement reports
84%

completion, and a mean exit-item score of 79% — both measured only on the children who engaged, which is the same variable that predicts the outcome.

What a holdout measures
+0.14 SD

on the same unit, over the same window — with an interval that comfortably spans zero, because a twelve-classroom pilot was never going to resolve it.

Be fair to engagement data. It is genuinely useful for diagnosis: which block children abandon, which item has an implausible score and is probably mis-keyed, whether a lesson is too long for a forty-minute period. Those are real jobs, and post 3’s rubric gets better because of them. It is just not evidence that your content taught anything, and it should never be the number in a sentence containing the word “improves”.

The design

Randomise where the intervention actually lands

A holdout in education is not a row filter. Children sit in classrooms, are taught by one teacher, and talk to each other — so assigning individual students to arms inside one room contaminates both arms and violates the independence the arithmetic assumes. The unit of randomisation is the section.

src/measurement/assignment.ts
import { createHash } from "node:crypto";

export interface Experiment {
  experimentId: string;         // "exp_fractions_pilot_q1"
  districtId: string;
  unitId: string;               // "unit_fractions_intro"
  arms: ["treatment", "control"];
  controlCondition: "existing_unit" | "no_supplement";   // what the control actually receives
  stratifyBy: ("school" | "prior_performance_quartile")[];
  startsAt: string;
  plannedEndAt: string;         // fixed before the first publish — see Power
  primaryOutcome: "posttest_score";
  mdeStandardised: number;      // 0.20 = detect a 0.2 SD effect
  assumedIcc: number;           // 0.15 — declared up front, because it drives everything
  owner: string;
}

/** Stable, storage-free assignment at the SECTION level. Students inherit their section's arm. */
export function armFor(sectionId: string, stratum: string, exp: Experiment): "treatment" | "control" {
  const digest = createHash("sha256").update(`${stratum}:${sectionId}:${exp.experimentId}`).digest();
  return digest.readUInt32BE(0) % 2 === 0 ? "treatment" : "control";
}
src/publish/experiment-filter.ts
import { armFor, type Experiment } from "../measurement/assignment.js";

const sections = await sectionsForCourse(scope, courseId);
const exp = await runningExperimentFor(scope, unitId);          // zero or one

const treated = exp
  ? sections.filter((s) => armFor(s.sectionId, stratumOf(s), exp) === "treatment")
  : sections;

// Diff happens AFTER the subtraction, so a control section published to before the
// experiment started is emitted as a removal rather than silently keeping treatment content.
const deltas = diffAgainstSnapshot(elementsFor(treated), lastSnapshot);
await lms.publish({ publishId, deltas });
await audit.write({ ...counts, experimentId: exp?.experimentId ?? null,
                    holdoutSections: sections.length - treated.length });
Why not use the platform's own analytics, or a commissioned efficacy study?
Run them. Platform analytics see things you cannot — actual time in the system, cross-course behaviour, attendance — and a third-party efficacy study carries credibility with districts that your own numbers never will. But note what you are accepting. The platform’s analytics have no arms in them at all; they are engagement data with a better pipeline. And a commissioned study is designed, run and reported by a party you are paying, on a timeline that ends before your renewal — which is not an accusation, it is just a set of incentives worth naming out loud. Run both; own one.

The overlap trap deserves its own paragraph, because it is silent and it is worse here than in most domains. A district running your pilot is also, that term, running a new intervention programme, a schedule change, and a different vendor’s pilot in the same grade — and neither experiment knows about the other. If the overlap is uneven between your arms, one study reads the other’s effect as its own. The fix is boring: a registry of running experiments per district, a check at creation that flags an intersection above a threshold, and — the part that requires a phone call rather than code — asking the district what else is happening in those rooms this term.

Power

Decide the sample size before you look, and put the clustering in it

The uncomfortable arithmetic goes first, not last. Children in the same classroom resemble each other, and that similarity inflates the sample you need by a design effect — at twenty-five children a room and a typical correlation, a factor of about 4.6. Detecting a 0.20 SD effect needs roughly 73 classrooms per arm. A typical pilot runs six.

workers/measurement/power.py
from math import ceil, sqrt

Z_ALPHA = 1.959964   # two-sided 0.05
Z_BETA = 0.841621    # 80% power


def design_effect(students_per_section: int, icc: float) -> float:
    """Children in one room are not independent observations. This is the price."""
    return 1.0 + (students_per_section - 1) * icc


def sections_per_arm(effect_size: float, students_per_section: int, icc: float) -> int:
    """Clusters required PER ARM to detect `effect_size` (in SD units) at 80% power."""
    per_arm_individual = 2 * (Z_ALPHA + Z_BETA) ** 2 / effect_size**2
    inflated = per_arm_individual * design_effect(students_per_section, icc)
    return ceil(inflated / students_per_section)


def min_detectable_effect(n_sections_per_arm: int, students_per_section: int, icc: float) -> float:
    """Inverted: given the classrooms you actually have, what is the smallest effect you can see?"""
    effective_n = (n_sections_per_arm * students_per_section) / design_effect(students_per_section, icc)
    return sqrt(2 * (Z_ALPHA + Z_BETA) ** 2 / effective_n)


# sections_per_arm(0.20, 25, 0.15)        -> 73   classrooms per arm
# min_detectable_effect(6, 25, 0.15)      -> 0.69 SD  <- what a six-classroom pilot can see
# min_detectable_effect(73, 25, 0.15)     -> 0.20 SD

The readout

Incremental learning, cost per point, and an interval

The query is unglamorous, which is the point. Assign every section to an arm with the same hash the publish engine used, join outcomes in the window, and compare arm means with the clustering respected. No engagement metric appears anywhere.

sql/efficacy_readout.sql
SELECT arm,
       count(DISTINCT section_id)                 AS sections,
       count(*)                                   AS students,
       avg(posttest_score)                        AS mean_score,
       stddev_samp(posttest_score)                AS sd_score,
       -- Between-section variance is what the design effect is made of. Report it; do not assume it.
       stddev_samp(section_mean)                  AS between_section_sd
  FROM (
    SELECT s.section_id,
           arm_for(s.section_id, s.stratum, $1)   AS arm,
           o.posttest_score,
           avg(o.posttest_score) OVER (PARTITION BY s.section_id) AS section_mean
      FROM sections AS s
      JOIN outcomes AS o
        ON  o.section_id = s.section_id
       AND o.assessed_at BETWEEN $2 AND $3
     WHERE s.district_id = $4 AND s.unit_id = $5
  ) AS t
 GROUP BY arm;
src/measurement/readout.ts
export interface EfficacyReadout {
  question: "superiority" | "non_inferiority";
  effectSize: number;            // standardised mean difference, treatment − control
  ciLow: number; ciHigh: number; // 95%, computed with the cluster-robust SE, never the naive one
  observedIcc: number;           // report what you measured, not what you assumed
  mdeAtObservedN: number;        // the honest ceiling on what this study could have seen
  underpowered: boolean;
  nonInferiorityMargin: number | null;
  verdict: "detected" | "no_detectable_effect" | "non_inferior" | "inconclusive";
}

Three things to say out loud. A result whose interval includes zero is a result — it says the effect, if any, is smaller than this study could see, and it should be reported in exactly those words rather than quietly not reported. The observed clustering belongs in the readout beside the assumed one, because if you planned at 0.15 and measured 0.28 your study was less powered than you thought and every future design should use the new number. And the interval must come from a cluster-robust estimator — which is the control you just drove at the top of this post.

The question

It is usually non-inferiority, not superiority

Here is the part almost nobody designs for. If a generated unit costs a fraction of an authored one, you do not need it to teach better. You need to know it does not teach appreciably worse. That is a different test with a different hypothesis, and running a superiority test and finding nothing is not the same as running a non-inferiority test and finding equivalence.

Margin you will acceptClassrooms per armWhat that actually is
0.05 SD1,156a multi-district study and a year
0.10 SD289a large district, or several
0.20 SD73an ambitious but reachable pilot

The ethics

The control arm is a room full of real children

A control section is a class you deliberately did not give the thing you believe is better, for a term, to learn something. That is a decision with subjects, an owner and a date, and it belongs in the same append-only audit log as every publish and every deletion — the experiment id and the held-back section count are already fields on post 4’s audit record.

Three practical consequences. The control condition should be business as usual, not nothing — you are comparing your unit against the district’s existing materials, which is both more ethical and the comparison a district actually wants. The district decides, with informed consent at the level its own policy requires; you propose a design, you do not assign children. And there is a stopping rule: if an interim look shows real harm, the study ends, and the rule for that is written down before the first publish rather than argued about in the moment.

Run it yourself · open-lesson-pipeline @ v6-efficacy
The seed is generated by a simulator that knows which mastery gains were caused by the unit and which were going to happen anyway — so the ground truth exists, and can be checked.
terminal
git clone https://github.com/xaviramirezcom/open-lesson-pipeline
cd open-lesson-pipeline
git checkout v6-efficacy
pnpm install && uv sync
pnpm lesson up
pnpm lesson seed --classrooms 12 --students-per-section 25 --icc 0.15

pnpm lesson experiment create exp_fractions_pilot_q1 \
  --district riverbend --unit unit_fractions_intro \
  --control existing_unit --stratify school,prior_quartile --mde 0.20
pnpm lesson publish --unit unit_fractions_intro --weeks 8

pnpm lesson report engagement --unit unit_fractions_intro
pnpm lesson report efficacy   --unit unit_fractions_intro --exp exp_fractions_pilot_q1
pnpm lesson report efficacy   --unit unit_fractions_intro --exp exp_fractions_pilot_q1 --naive-se
pnpm lesson power --sections-per-arm 6 --students 25 --icc 0.15
The engagement run reports 84% completion and a mean exit-item score of 79%, which reads like success. The efficacy run on the same unit over the same window reports an effect of 0.14 SD with an interval from −0.31 to +0.59, a minimum detectable effect of 0.69, and a verdict of no detectable effect — and the simulator’s ground-truth file says the real effect was 0.18, which this design was never going to see. The naive run is the one to sit with: ignore the clustering and the same data produces an interval that excludes zero and would have been published as a win. Same children, same scores, one modelling assumption, opposite conclusions.

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 →

What you have now

  1. A request that is a contract. Scope, framework version and grade band pinned at the edge, with generation behind a log — so a provider outage is consumer lag rather than a broken first period.
  2. Alignment claims you can retract. A graph of typed nodes and classed edges with provenance, so a bad claim is a removable observation rather than permanent damage, and coverage is reported per class or not at all.
  3. Appropriateness that is measured, not asserted. Four properties over immutable blocks, one rubric loaded by two runtimes, and an evaluation whose labels come from teachers rather than a model with the generator's blind spots.
  4. A guardrail the editor cannot route around. Publishability derived from an inner join to gate decisions, review receipts and asset rights, so a pasted paragraph and an uploaded photograph are gated exactly like generated content.
  5. Publishes that send deltas and report their own fidelity. Diffs rather than re-imports, idempotent elements with stable external ids, dead-lettered rejects, and a freshness target that catches the expired token before a term does.
  6. A number that survives “compared to what?”. Randomisation at the section level, a power calculation with the clustering in it, and a readout that knows whether it is answering superiority or non-inferiority.
The part that is not the code

That is the pipeline, and it is honest to say the parts shown here are the easy parts. A queue consumer, a component solver, a rubric in a file, a diff engine, a hash-based assignment — these are solved problems with known shapes, and a competent team can build all of them in a quarter. The hard part in any real company is none of that. It is the organisational agreement about what “covered” means: whether an inferred alignment counts, whether a teacher’s edit means the content was wrong or that she has a strong voice, whether a 0.08 SD shortfall is an acceptable price for content a district could not otherwise afford, whether a photograph of a classroom is a resource or a child’s data. Every one of those is a curriculum decision wearing an engineering costume, and no amount of correct code resolves them. Build the pipeline so that when the answers change, you can change them in one place — and so that the audit log can always say who decided, and when.

Explain it back

A research team runs a clean pilot on a generated unit. The mechanics are right: section-level deterministic assignment, publish-engine exclusion of the control arm, a fixed end date, stratified randomisation. The readout comes back at 0.14 SD with a 95% interval from −0.31 to +0.59. They post it in the channel as “we measured a 0.14 SD improvement” and propose district-wide rollout. What is wrong with that sentence, what should the readout have said, and what should the team do next?
Reveal a model answer

The sentence reports a point estimate as if it were the finding. It is not — the interval contains zero and it contains −0.31, so the study is equally consistent with the unit costing children a third of a standard deviation. “0.14” is the centre of a range whose honest summary is “we could not detect an effect”. The mechanism is that the pilot was underpowered for any effect size that is plausible in education: twenty-four classrooms, twenty-five children each, and therefore a minimum detectable effect near 0.5 SD — several times larger than anything a supplemental unit realistically produces.

The readout should have said: no detectable effect; 0.14 SD, 95% CI −0.31 to +0.59; minimum detectable effect at this sample 0.49 SD; observed clustering 0.17 against an assumed 0.15. Every one of those numbers changes what a reader does next.

What the team should do next depends on which question they actually care about, and they should pick before collecting more data. If the claim they want is “as good as what you have, far cheaper”, the study should have been a non-inferiority design with a margin the curriculum director signed off on — and they should price that honestly, because a 0.10 SD margin needs something on the order of 289 classrooms per arm, which is a multi-district study and a year, not a pilot. If that is not affordable, the correct move is to stop making efficacy claims at this resolution and test something coarser: hold out an entire grade level for a term and measure the aggregate, accepting a blunter answer honestly rather than a precise one that is noise.

The bonus consequence is political rather than statistical. Once “0.14 SD improvement” is in a channel it gets pasted into a renewal deck, and it becomes a number nobody can retract without appearing to attack the team that produced it — and, eventually, a claim a district repeats to parents. The interval is what stops that, which is why it belongs in the sentence and not in a footnote.

The series ends here · start again at 00
Six stages, from a curriculum code to a number you can defend

Every stage in this pipeline is a bet that an artefact teaches what it is labelled. Re-read the map with all six posts behind you and the chain reads differently — every hop is now a place you know how to corrupt a lesson, and how not to.

Back to the map