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..2f35c62 --- /dev/null +++ b/test/fixtures.test.ts @@ -0,0 +1,255 @@ +/** + * 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. + // + // 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( + result.unverifiable, + undefined, + 'nothing yet reports which accommodations went unchecked; when that lands, this test should ' + + 'assert the guarantee instead of the gap', + ); +}); + +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; +}