From ff80f83174d64779448bcea50a6ca41ac1d4be85 Mon Sep 17 00:00:00 2001 From: inquerium Date: Mon, 3 Aug 2026 12:25:58 -0500 Subject: [PATCH 1/2] fixtures: a corpus with ground truth, and the defect it found Every claim this repository makes about the mastery model was checkable only by reading the code. test/fixtures/ makes them checkable by running it: eight synthetic learners, each built to exhibit one property, each carrying an expect block stating what is true of it by construction. They are also the substrate the pipeline needs. docs/OPENCLAW.md already says all pipeline work uses synthetic fixtures in test/fixtures/, and pointed at a directory that did not exist. Nothing that examines the record, the scheduler, or a generated activity could be measured against anything until it did. test/fixtures.test.ts holds the corpus to its own claims. That file is the point of the exercise. A persona that quietly stops exhibiting the property it is named for does not fail loudly, it makes everything measured against it report success against nothing. Two properties worth naming, because both were assumptions until they were arithmetic. Eight correct answers across widening gaps buy a half-life of about sixty-five days, so a well-spaced skill survives a summer away and only lapses around a hundred and fifty. The same eight answers crammed into six consecutive days lapse in sixty. lapsed-summer carries both, in one child, so the spacing effect is visible as a difference rather than asserted as a claim. vowel-confuser is deliberately unlabelled. It carries a real misconception in the response column, bed read as bid and pen as pin, with short i mastered so that not knowing short i is ruled out, and no misconception row at all. A detector scored against a record that already names the answer has found nothing. The corpus found something on the first run. nextTargets tested opportunities >= 6 && p < 0.5 before it tested lapsed, and six opportunities is an ordinary number of tries to learn something, so a child who mastered a skill in spring and took the summer off came back reported as stuck. The tutor was told to change modality or drop to a prerequisite for a child who needed a two minute review of something she used to know. peak_p_known is the only thing that can separate lost it from never had it, statusOf already reads it, and the stuck branch now consults it. Co-Authored-By: Claude Opus 5 (1M context) --- src/domain/scheduler.ts | 11 +- test/fixtures.test.ts | 244 ++++++++++++++++++++++++ test/fixtures/personas.ts | 381 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 635 insertions(+), 1 deletion(-) create mode 100644 test/fixtures.test.ts create mode 100644 test/fixtures/personas.ts diff --git a/src/domain/scheduler.ts b/src/domain/scheduler.ts index 1a26ead..b1b76c2 100644 --- a/src/domain/scheduler.ts +++ b/src/domain/scheduler.ts @@ -103,12 +103,21 @@ export function nextTargets(learnerId: string, opts: TargetOptions = {}): Target const neverLanded = p < 0.25 && opportunities < 5; const reviewDue = Boolean(includeReview && m?.next_due && new Date(m.next_due) <= at); - if (opportunities >= 6 && p < 0.5) { + if (opportunities >= 6 && p < 0.5 && status !== 'lapsed') { // Checked before everything else. A failing skill's interval collapses to // hours, so if `review_due` is tested first this signal is visible for about // two hours after the session and then vanishes — meaning the tutor is told // "try something different" essentially never, since the next run is the next // day. A child failing repeatedly is the most important thing in the queue. + // + // But stuck means never landed, not held and then forgotten. Six tries is an + // ordinary number to learn something, so without the lapsed guard a child who + // mastered a skill in spring and took the summer off comes back reported as + // stuck: the tutor is told to change modality or drop to a prerequisite, when + // the right move is a two-minute review of something she used to know. The + // record already holds the distinction in `peak_p_known`, which is the only + // thing that can tell "lost it" from "never had it", and `statusOf` is where + // that reading lives. reason = 'stuck'; priority = 0.95; } else if (m && status === 'lapsed') { diff --git a/test/fixtures.test.ts b/test/fixtures.test.ts new file mode 100644 index 0000000..7300040 --- /dev/null +++ b/test/fixtures.test.ts @@ -0,0 +1,244 @@ +/** + * The fixture corpus is a set of claims about the model, made in evidence. + * This file holds it to them. + * + * Without this, a persona that quietly stops exhibiting the property it is named + * for does not fail loudly. It makes every agent that measures itself against + * the corpus report success against nothing, which is worse than having no + * corpus at all. + */ + +import { test, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const home = mkdtempSync(join(tmpdir(), 'primer-fixtures-')); +process.env.PRIMER_HOME = home; +process.env.PRIMER_DB = join(home, 'record.db'); + +const { db, closeDb, all } = await import('../src/db/index.ts'); +const { buildPersona, buildAll, PERSONAS, AS_OF } = await import('./fixtures/personas.ts'); +const { nextTargets } = await import('../src/domain/scheduler.ts'); +const { learnerContext } = await import('../src/record/context.ts'); +const { getMastery } = await import('../src/record/mastery.ts'); +const { statusOf, nextDue } = await import('../src/domain/bkt.ts'); +const { toState } = await import('../src/record/mastery.ts'); + +before(() => { + db(); +}); + +after(() => { + closeDb(); + rmSync(home, { recursive: true, force: true }); +}); + +test('every persona builds, and builds a distinct learner', () => { + const built = buildAll(); + assert.equal(Object.keys(built).length, PERSONAS.length); + const ids = new Set(Object.values(built).map((b) => b.learner_id)); + assert.equal(ids.size, PERSONAS.length, 'personas must not share a learner row'); +}); + +test('cold-start: an older empty record opens placement', () => { + const { learner_id } = buildPersona('cold-start'); + const ctx = learnerContext(learner_id, { at: AS_OF }); + + assert.ok(ctx.placement, 'an eight-year-old with no evidence must be placed before teaching'); + assert.equal(ctx.placement.domains.reading.status, 'in_progress'); + assert.ok(ctx.placement.domains.reading.probes.length > 0, 'placement must say what to probe'); + assert.equal( + all<{ n: number }>(`SELECT count(*) AS n FROM mastery WHERE learner_id = ?`, learner_id)[0]!.n, + 0, + ); + assert.ok(ctx.learner.expected_bands, 'age gives a starting guess'); + + const targets = nextTargets(learner_id, { at: AS_OF }); + assert.ok(targets.length > 0); + assert.ok( + targets.every((t) => t.reason === 'frontier'), + `nothing has been seen, so nothing can be lapsed or stuck: ${[...new Set(targets.map((t) => t.reason))]}`, + ); +}); + +test('cold-start-young: placement is withheld on purpose, not missing', () => { + const { learner_id } = buildPersona('cold-start-young'); + const ctx = learnerContext(learner_id, { at: AS_OF }); + + // For a five-year-old the roots of the graph are the right first material, so + // there is nothing to place. Pinning it here keeps a future refactor from + // "fixing" the null and marching every kindergartener through a probe session. + assert.equal(ctx.placement, null); + assert.equal(ctx.learner.expected_bands?.primary, 'k'); +}); + +test('stuck-blending: the stuck skill surfaces, and outranks everything else', () => { + const { learner_id } = buildPersona('stuck-blending'); + const targets = nextTargets(learner_id, { at: AS_OF }); + + const blend = targets.find((t) => t.skill.id === 'pa_phoneme_blend_3'); + assert.ok(blend, 'the skill with nine failed attempts must appear in the queue'); + assert.equal(blend.reason, 'stuck'); + assert.ok(blend.opportunities >= 6, `condition is opportunities >= 6, got ${blend.opportunities}`); + assert.ok(blend.p_known < 0.5, `condition is p_known < 0.5, got ${blend.p_known}`); + + // A child failing repeatedly is the most important thing in the queue. If a + // lapsed skill outranks it, the tutor is told "quick win" when it should be + // told "change approach". + assert.equal(targets[0]!.skill.id, 'pa_phoneme_blend_3', 'stuck must sort to the top'); +}); + +test('lapsed-summer: cramming lapses over a summer, spacing survives it', () => { + const { learner_id } = buildPersona('lapsed-summer'); + const crammed = ['al_letter_sounds_short_vowels', 'ph_cvc_short_a', 'ph_cvc_short_i']; + + for (const skillId of crammed) { + const m = getMastery(learner_id, skillId); + assert.ok(m, `${skillId} should have mastery state`); + const state = toState(m); + + assert.equal(statusOf(state, AS_OF), 'lapsed', `${skillId} should read as lapsed at AS_OF`); + assert.ok( + (state.peak_p_known ?? 0) >= 0.95, + `${skillId} must have genuinely been held once, peak was ${state.peak_p_known}`, + ); + // The stability floor is what stops a practiced skill decaying to zero. It is + // the difference between "review this" and "teach this again from scratch". + assert.ok(state.p_known > 0, `${skillId} should not have decayed to nothing`); + } + + // Same child, same absence, same number of correct answers. Only the spacing + // differed, and it is the whole difference between holding and losing. + const spaced = getMastery(learner_id, 'ph_cvc_short_o'); + assert.ok(spaced); + const spacedState = toState(spaced); + assert.equal( + statusOf(spacedState, AS_OF), + 'mastered', + 'a well-spaced skill must survive a summer; if this flips, the spacing model moved', + ); + assert.ok( + spacedState.half_life_days > toState(getMastery(learner_id, 'ph_cvc_short_a')!).half_life_days * 2, + 'spacing must buy a materially longer half-life than cramming the same count', + ); + + // Forgotten is not the same as stuck, and the queue has to say which. + // + // Six opportunities is an ordinary number of tries to learn something, so + // before the lapsed guard in scheduler.ts these three tripped the stuck branch + // and the tutor was told to change modality or drop to a prerequisite for a + // child who simply took the summer off. `peak_p_known` is the only thing that + // separates "lost it" from "never had it", and this is where that matters. + const targets = nextTargets(learner_id, { at: AS_OF }); + const byId = new Map(targets.map((t) => [t.skill.id, t.reason])); + for (const skillId of crammed) { + assert.equal(byId.get(skillId), 'lapsed', `${skillId} was held and forgotten, so it is a review`); + } + assert.ok( + targets.filter((t) => t.reason === 'stuck').length === 0, + `nothing on this record has failed to land: ${JSON.stringify([...byId])}`, + ); +}); + +test('crammer: one sitting buys weeks of retention, not months', () => { + const { learner_id } = buildPersona('crammer'); + const m = getMastery(learner_id, 'cc_count_to_20'); + assert.ok(m); + const state = toState(m); + + assert.equal(state.opportunities, 20); + // Twenty items answered in one sitting are one practice occasion. The gain + // floor in applyEvidence is what enforces that, and this is the number that + // moves if anyone removes it. + assert.ok( + state.half_life_days < 30, + `cramming must not buy a long interval, half-life was ${state.half_life_days} days`, + ); + + const due = nextDue(state); + assert.ok(due); + const daysOut = (new Date(due).getTime() - new Date(state.last_seen!).getTime()) / 86_400_000; + assert.ok(daysOut < 60, `next review was ${Math.round(daysOut)} days out; that is a season away`); +}); + +test('vowel-confuser: the misconception is in the responses, and nothing has labelled it', () => { + const { learner_id } = buildPersona('vowel-confuser'); + + const wrong = all<{ item: string; response: string; expected: string }>( + `SELECT item, response, expected FROM observation + WHERE learner_id = ? AND skill_id = 'ph_cvc_short_e' AND correct < 0.6`, + learner_id, + ); + assert.ok(wrong.length >= 8, `expected a real run of errors, got ${wrong.length}`); + + // Every miss is the same substitution. That regularity is the entire signal a + // miner has to work with, and it must survive in the record verbatim. + for (const row of wrong) { + assert.notEqual(row.response, row.expected); + assert.equal( + row.response, + row.expected.replace(/e/, 'i'), + `${row.item} should have been misread as its short-i twin, got "${row.response}"`, + ); + } + + // Short i is solid, so "she does not know short i" is ruled out by construction. + const shortI = getMastery(learner_id, 'ph_cvc_short_i'); + assert.ok(shortI); + assert.equal(statusOf(toState(shortI), AS_OF), 'mastered'); + + // Ground truth means unlabelled. A miner that finds this has found it; a miner + // scored against a record that already names the answer has found nothing. + const labelled = all<{ n: number }>( + `SELECT count(*) AS n FROM misconception WHERE learner_id = ?`, + learner_id, + )[0]!.n; + assert.equal(labelled, 0, 'the corpus must not hand the miner its own answer'); +}); + +test('accommodated: three hard constraints, none of them enforced by the validator', async () => { + const { learner_id } = buildPersona('accommodated'); + const ctx = learnerContext(learner_id, { at: AS_OF }); + + const kinds = ctx.accommodations.map((a) => a.kind).sort(); + assert.deepEqual(kinds, ['audio_first', 'dyslexia_typography', 'no_timers']); + for (const a of ctx.accommodations) { + assert.ok(a.detail && a.detail.length > 20, `${a.kind} needs actionable detail, not a label`); + } + + // Pinning the gap rather than asserting the guarantee. SPEC.md calls honoring + // accommodations a hard constraint; validateInterface takes an HTML string and + // never learns who it is for, so today the constraint lives only in the prompt. + // When accommodation-marshal closes this, the assertion below is what changes. + const { validateInterface } = await import('../src/surface/validate.ts'); + assert.equal( + validateInterface.length, + 1, + 'validateInterface still takes only html; it cannot check accommodations it cannot see', + ); +}); + +test('above-band: upstream skills are demoted, never walled off', () => { + const { learner_id } = buildPersona('above-band'); + const ctx = learnerContext(learner_id, { at: AS_OF }); + + // Her age says k and 1. Her evidence says otherwise, and the evidence wins. + assert.ok(ctx.learner.expected_bands); + const pv = getMastery(learner_id, 'pv_teen_numbers'); + assert.ok(pv); + assert.equal(statusOf(toState(pv), AS_OF), 'mastered'); + + const targets = nextTargets(learner_id, { at: AS_OF, limit: 200 }); + const countToTen = targets.find((t) => t.skill.id === 'cc_count_to_10'); + + // Reachable, because a child who stalls downstream needs a route back to the + // foundation. But not offered first to a child already doing place value. + assert.ok(countToTen, 'implied-known skills must stay in the graph, not be deleted from it'); + const rank = targets.findIndex((t) => t.skill.id === 'cc_count_to_10'); + assert.ok( + rank > 3, + `"Counts to 10" was ranked ${rank} for a child doing teen place value; implied-known is not demoting it`, + ); +}); diff --git a/test/fixtures/personas.ts b/test/fixtures/personas.ts new file mode 100644 index 0000000..8c60b89 --- /dev/null +++ b/test/fixtures/personas.ts @@ -0,0 +1,381 @@ +/** + * Synthetic learner records, built to order. + * + * Every pipeline agent works against these and never against a real family + * record. That boundary is stated in docs/OPENCLAW.md and it is the reason this + * directory exists: an agent holding a path to a `.db` file it did not build + * from a persona here is a mistake or an attack, and has no legitimate task. + * + * Each persona is a claim about the model, made in evidence rather than in + * prose. `stuck-blending` is not a child who is loosely struggling: it is nine + * attempts and two correct answers, which is the exact shape that makes + * `nextTargets` return reason 'stuck'. An agent measuring a detector against + * these needs to know what is true by construction, so every persona carries an + * `expect` block saying so, and test/fixtures.test.ts holds the corpus to it. + * + * That test is the point. A fixture that quietly stops exhibiting the property + * it is named for does not fail loudly; it makes every agent downstream of it + * report success against nothing. + */ + +import { loadCurriculum } from '../../src/curriculum/load.ts'; +import { createLearner, addAccommodation, noteInterest } from '../../src/record/learners.ts'; +import { recordObservations, type ObservationInput } from '../../src/record/observations.ts'; + +/** + * One fixed instant the whole corpus is written relative to. + * + * Personas that depend on elapsed time (`lapsed-summer`, `crammer`) are only + * meaningful when read at a known "now". Building against `new Date()` makes a + * fixture whose properties drift with the wall clock, which is how a corpus + * turns into a flaky test suite nobody trusts. + */ +export const AS_OF = new Date('2026-08-03T12:00:00.000Z'); + +/** `days` before AS_OF, as an ISO timestamp. */ +export function daysAgo(days: number, hourOffset = 0): string { + return new Date(AS_OF.getTime() - days * 86_400_000 + hourOffset * 3_600_000).toISOString(); +} + +/** A birth date that lands on exactly `years` old at AS_OF. */ +function bornAtAge(years: number): string { + const d = new Date(AS_OF); + d.setUTCFullYear(d.getUTCFullYear() - years); + return d.toISOString().slice(0, 10); +} + +export interface Persona { + id: string; + /** One line, for an agent deciding whether this is the fixture it needs. */ + summary: string; + /** + * What is true of this record by construction. An agent measuring a detector + * scores itself against these, not against its own judgment of the output. + */ + expect: string[]; + build(): BuiltPersona; +} + +export interface BuiltPersona { + learner_id: string; + name: string; + /** Read the record at this instant, or the time-dependent claims do not hold. */ + as_of: Date; +} + +/** Write a run of attempts at one skill, spaced across days. */ +function attempts( + learnerId: string, + skillId: string, + results: Array<{ correct: number; day: number; item?: string; response?: string; expected?: string }>, +): void { + const inputs: ObservationInput[] = results.map((r) => ({ + skill_id: skillId, + kind: 'attempt', + correct: r.correct, + latency_ms: r.correct >= 0.6 ? 2400 : 7100, + item: r.item ?? null, + response: r.response ?? null, + expected: r.expected ?? null, + modality: 'selected', + source: 'fixture', + ts: daysAgo(r.day), + })); + recordObservations(learnerId, inputs, 'fixture'); +} + +/** Drive a skill to mastery: eight correct answers across widening gaps. */ +function mastered(learnerId: string, skillId: string, startedDaysAgo: number): void { + const gaps = [0, 1, 3, 6, 10, 15, 21, 28]; + attempts( + learnerId, + skillId, + gaps.map((g) => ({ correct: 1, day: startedDaysAgo - g })), + ); +} + +/* ------------------------------------------------------------- the corpus -- */ + +export const PERSONAS: Persona[] = [ + { + id: 'cold-start', + summary: 'Eight years old, nothing on record. The case placement exists for.', + expect: [ + 'learnerContext().placement is non-null and reading is in_progress', + 'no mastery rows exist', + 'nextTargets returns only reason "frontier"', + 'expected_bands comes from age alone: a starting guess, never a verdict', + 'without placement she would be offered rhyme recognition on day one', + ], + build() { + // Age is load-bearing. placement.ts returns null for pk and k children + // because for them the roots of the graph are the right starting point. + // The failure placement exists to prevent belongs to the older child who + // walks in knowing things and would otherwise grind up from pencil grip. + const learner = createLearner({ + display_name: 'Wren', + birth_date: bornAtAge(8), + pronouns: 'they/them', + }); + noteInterest(learner.id, 'trucks', { source: 'parent' }); + return { learner_id: learner.id, name: learner.display_name, as_of: AS_OF }; + }, + }, + + { + id: 'cold-start-young', + summary: 'Five years old, nothing on record. Placement is deliberately not offered.', + expect: [ + 'learnerContext().placement is null, because the expected band is k', + 'this is the boundary at placement.ts:230, and it is a decision rather than a gap', + 'the roots of the graph are the right first material for this child', + ], + build() { + const learner = createLearner({ + display_name: 'Bo', + birth_date: bornAtAge(5), + pronouns: 'he/him', + }); + noteInterest(learner.id, 'diggers', { source: 'parent' }); + return { learner_id: learner.id, name: learner.display_name, as_of: AS_OF }; + }, + }, + + { + id: 'stuck-blending', + summary: 'Six years old, nine attempts at blending three sounds and still failing.', + expect: [ + 'nextTargets includes pa_phoneme_blend_3 with reason "stuck"', + 'that target outranks every lapsed and review_due target in the queue', + 'opportunities >= 6 and p_known < 0.5, which is the condition scheduler.ts tests first', + ], + build() { + const learner = createLearner({ + display_name: 'Ida', + birth_date: bornAtAge(6), + pronouns: 'she/her', + }); + mastered(learner.id, 'pa_phoneme_isolate_initial', 60); + // Nine attempts, two correct, spread over three weeks. Repetition without + // movement is the signal: the scheduler must say "try something else" + // rather than offering the same drill again. + attempts(learner.id, 'pa_phoneme_blend_3', [ + { correct: 0, day: 21 }, + { correct: 0, day: 19 }, + { correct: 1, day: 16 }, + { correct: 0, day: 14 }, + { correct: 0, day: 11 }, + { correct: 0, day: 9 }, + { correct: 1, day: 6 }, + { correct: 0, day: 4 }, + { correct: 0, day: 2 }, + ]); + noteInterest(learner.id, 'horses', { source: 'observed' }); + return { learner_id: learner.id, name: learner.display_name, as_of: AS_OF }; + }, + }, + + { + id: 'lapsed-summer', + summary: + 'Crammed three skills in spring and took the summer off. A fourth was spaced properly and survived it.', + expect: [ + 'the three crammed skills read "lapsed" at AS_OF and surface with reason "lapsed"', + 'ph_cvc_short_o was practiced across widening gaps and is still "mastered" after the same summer', + 'that contrast is the spacing effect, visible in one record', + 'peak_p_known is what separates all four from skills never learned', + 'stabilityFloor keeps the lapsed three above zero, so they are review and not relearning', + ], + build() { + const learner = createLearner({ + display_name: 'Cyrus', + birth_date: bornAtAge(7), + pronouns: 'he/him', + }); + // Six reps on six consecutive days buys a half-life near two weeks. A + // summer away is then five half-lives, and it is gone. + for (const skill of ['al_letter_sounds_short_vowels', 'ph_cvc_short_a', 'ph_cvc_short_i']) { + attempts( + learner.id, + skill, + [0, 1, 2, 3, 4, 5].map((g) => ({ correct: 1, day: 100 - g })), + ); + } + // The same number of correct answers, spread. Two months of half-life, and + // the identical absence leaves it intact. Nothing about the child differs. + mastered(learner.id, 'ph_cvc_short_o', 110); + noteInterest(learner.id, 'sharks', { source: 'child' }); + return { learner_id: learner.id, name: learner.display_name, as_of: AS_OF }; + }, + }, + + { + id: 'crammer', + summary: 'Twenty correct answers in one sitting, and nothing before or since.', + expect: [ + 'half_life_days stays under 30: one sitting is one practice occasion, not twenty', + 'next_due lands within weeks, never months', + 'the same twenty answers spread across widening gaps would earn far more retention', + ], + build() { + const learner = createLearner({ + display_name: 'Nia', + birth_date: bornAtAge(6), + pronouns: 'she/her', + }); + // All on one day, minutes apart. The spacing model exists to stop this + // buying a three-month interval. + attempts( + learner.id, + 'cc_count_to_20', + Array.from({ length: 20 }, (_, i) => ({ correct: 1, day: 9 - i * 0.002 })), + ); + return { learner_id: learner.id, name: learner.display_name, as_of: AS_OF }; + }, + }, + + { + id: 'vowel-confuser', + summary: + 'Reads every short e as short i. The misconception is in the response column, by construction.', + expect: [ + 'every wrong answer on ph_cvc_short_e substitutes the short i word: bed -> bid, pen -> pin', + 'the same substitution appears on pa_phoneme_isolate_medial, so it is a model and not a word gap', + 'ph_cvc_short_i is mastered, which rules out "does not know short i"', + 'no misconception row exists: this corpus is the ground truth a miner must find unaided', + ], + build() { + const learner = createLearner({ + display_name: 'Ada', + birth_date: bornAtAge(6), + pronouns: 'she/her', + }); + mastered(learner.id, 'ph_cvc_short_a', 90); + mastered(learner.id, 'ph_cvc_short_i', 75); + mastered(learner.id, 'ph_cvc_short_o', 60); + mastered(learner.id, 'ph_cvc_short_u', 45); + + // The tell is the response text, not the score. A detector that reads only + // `correct` cannot tell this child from one who is simply guessing, and + // that difference is the whole of what a good tutor carries in their head. + const pairs = [ + ['bed', 'bid'], + ['pen', 'pin'], + ['den', 'din'], + ['bet', 'bit'], + ['pet', 'pit'], + ['led', 'lid'], + ['ten', 'tin'], + ['beg', 'big'], + ]; + attempts( + learner.id, + 'ph_cvc_short_e', + pairs.map(([expected, said], i) => ({ + correct: 0, + day: 20 - i * 2, + item: expected!, + response: said!, + expected: expected!, + })), + ); + attempts(learner.id, 'pa_phoneme_isolate_medial', [ + { correct: 0, day: 12, item: 'bed', response: 'i', expected: 'e' }, + { correct: 0, day: 9, item: 'net', response: 'i', expected: 'e' }, + { correct: 1, day: 7, item: 'cat', response: 'a', expected: 'a' }, + { correct: 0, day: 5, item: 'leg', response: 'i', expected: 'e' }, + { correct: 1, day: 3, item: 'dog', response: 'o', expected: 'o' }, + ]); + noteInterest(learner.id, 'Biscuit the dog', { source: 'child', weight: 0.6 }); + return { learner_id: learner.id, name: learner.display_name, as_of: AS_OF }; + }, + }, + + { + id: 'accommodated', + summary: 'Three standing accommodations. Every generated activity must honor all of them.', + expect: [ + 'learnerContext().accommodations has exactly three active entries', + 'dyslexia_typography, no_timers, and audio_first are all present', + 'an activity containing a countdown timer is a violation, not a warning', + 'nothing in validateInterface currently checks any of this', + ], + build() { + const learner = createLearner({ + display_name: 'Tomas', + birth_date: bornAtAge(8), + pronouns: 'he/him', + }); + addAccommodation( + learner.id, + 'dyslexia_typography', + 'Sans-serif, at least 18px, extra letter spacing, no italics, left-aligned ragged right.', + ); + addAccommodation( + learner.id, + 'no_timers', + 'No countdowns, no clocks, no time pressure of any kind. Timing anything visibly stops him working.', + ); + addAccommodation( + learner.id, + 'audio_first', + 'Every instruction is spoken before it is written. He will not ask for it to be repeated.', + ); + mastered(learner.id, 'ph_cvc_mixed', 40); + noteInterest(learner.id, 'volcanoes', { source: 'child' }); + return { learner_id: learner.id, name: learner.display_name, as_of: AS_OF }; + }, + }, + + { + id: 'above-band', + summary: 'Six years old and working two grades up. The expected band is wrong about her.', + expect: [ + 'evidence sits in grade 2 place value while expected_bands from age says k and 1', + 'impliedKnown pushes counting-to-ten style prerequisites down the queue rather than deleting them', + 'those implied skills stay reachable, so a stall downstream still has a route back', + 'a scheduler that treats unobserved as unknown would offer her "Counts to 10"', + ], + build() { + const learner = createLearner({ + display_name: 'Suri', + birth_date: bornAtAge(6), + pronouns: 'she/her', + }); + // Nothing upstream is ever formally assessed. Competence is demonstrated + // from above, which is exactly the case impliedKnown exists to handle. + mastered(learner.id, 'pv_teen_numbers', 70); + mastered(learner.id, 'cc_count_by_10s', 55); + noteInterest(learner.id, 'space', { source: 'child', weight: 0.7 }); + return { learner_id: learner.id, name: learner.display_name, as_of: AS_OF }; + }, + }, +]; + +export function persona(id: string): Persona { + const found = PERSONAS.find((p) => p.id === id); + if (!found) { + throw new Error(`no persona "${id}". Known: ${PERSONAS.map((p) => p.id).join(', ')}`); + } + return found; +} + +/** + * Build one persona into the currently open record. + * + * The caller owns the database. Point PRIMER_DB at a temporary file first; this + * never opens or creates a record on its own, so there is no path by which it + * can write into one that matters. + */ +export function buildPersona(id: string): BuiltPersona { + loadCurriculum(); + return persona(id).build(); +} + +/** Build every persona into one record. Each is an independent learner. */ +export function buildAll(): Record { + loadCurriculum(); + const out: Record = {}; + for (const p of PERSONAS) out[p.id] = p.build(); + return out; +} From d44f98347bfb1c094779df94da4b546bacc99e22 Mon Sep 17 00:00:00 2001 From: inquerium Date: Mon, 3 Aug 2026 15:50:43 -0500 Subject: [PATCH 2/2] fixtures: pin the accommodation gap by behavior, not by arity The assertion read `validateInterface.length === 1`, meaning to fail the day the validator learned who an activity was for. It would not have. Giving a function a second parameter with a default leaves Function.length at 1, so the check would have kept passing after the capability landed and would have quietly asserted nothing. That is precisely the silent rot this corpus exists to prevent, in the corpus. A persona that stops exhibiting its property fails loudly; a pin that stops pinning does not. So the gap is now pinned by behavior: a page setting 9px text is accepted today, and nothing reports which accommodations went unchecked. Both statements become false the moment the constraint is enforced, and the test says what to assert instead. Co-Authored-By: Claude Opus 5 (1M context) --- test/fixtures.test.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/test/fixtures.test.ts b/test/fixtures.test.ts index 7300040..2f35c62 100644 --- a/test/fixtures.test.ts +++ b/test/fixtures.test.ts @@ -211,12 +211,23 @@ test('accommodated: three hard constraints, none of them enforced by the validat // Pinning the gap rather than asserting the guarantee. SPEC.md calls honoring // accommodations a hard constraint; validateInterface takes an HTML string and // never learns who it is for, so today the constraint lives only in the prompt. - // When accommodation-marshal closes this, the assertion below is what changes. + // + // Pinned by behavior, not by arity. Giving the validator a second parameter + // with a default leaves Function.length at 1, so an arity check would keep + // passing after the capability landed and would quietly assert nothing. That + // is the exact silent rot this corpus exists to prevent, so the pin is a page + // that violates a real accommodation and is currently accepted anyway. const { validateInterface } = await import('../src/surface/validate.ts'); + const tiny = '

go

' + + '' + + ''; + const result = validateInterface(tiny) as { ok: boolean; unverifiable?: unknown[] }; + assert.equal(result.ok, true, '9px text is accepted today for a child who needs 18px'); assert.equal( - validateInterface.length, - 1, - 'validateInterface still takes only html; it cannot check accommodations it cannot see', + result.unverifiable, + undefined, + 'nothing yet reports which accommodations went unchecked; when that lands, this test should ' + + 'assert the guarantee instead of the gap', ); });