diff --git a/README.md b/README.md index 5241794..4ae889c 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The app deliberately begins with a small, verified straight-subdivision catalog. ## Release information -- **Build:** `2026-08-08.3` +- **Build:** `2026-08-08.4` - **Status:** MVP built and publicly available - **Live app:** - **Public app guide:** diff --git a/app/CountItApp.tsx b/app/CountItApp.tsx index ead111c..39f96f4 100644 --- a/app/CountItApp.tsx +++ b/app/CountItApp.tsx @@ -34,6 +34,8 @@ import { type Assignment, type AssignmentError, } from "../src/assignment"; +import { createPraxisEvidenceResult } from "../src/result"; +import { parseSequenceStep, type SequenceStep } from "../src/sequence-step"; type AppMode = "practice" | "challenge"; @@ -276,6 +278,7 @@ function ChallengeMode({ showReference, personalBest, assignment, + sequenceStep, studentId, finishedAt, onStudentIdChange, @@ -290,6 +293,7 @@ function ChallengeMode({ showReference: boolean; personalBest: number; assignment: Assignment | null; + sequenceStep: SequenceStep | null; studentId: string; finishedAt: Date | null; onStudentIdChange: (value: string) => void; @@ -329,6 +333,17 @@ function ChallengeMode({ ? "Good work. Review the highlighted subdivisions, then try again." : "Keep the guide visible and work beat by beat. Accuracy will follow."; const stamped = finishedAt ?? new Date(0); + /* The evidence, built once and rendered from. Before this the card assembled + its own facts inline, which is why this app had no result object to + migrate — and why the human record and any machine record could have + described different rounds without anything noticing. */ + const result = createPraxisEvidenceResult({ + session, assignment, sequenceStep, level, scope, finishedAt: stamped, + }); + /* Separate from result.attemptReference on purpose: this is the + teacher-facing code with its own published format and its own stated + limits, and whether it generalizes across the three apps is still open. + Making the two identical here would answer that by accident. */ const code = verificationCode({ assignment: assignment?.name ?? "", studentId, @@ -336,10 +351,8 @@ function ChallengeMode({ total: session.questions.length, finishedAt: stamped, }); - const conditions = assignment - ? describeAssignment(assignment) - : `${getLevel(level).shortName} · ${scope === "beat" ? "one-beat" : "one-measure"} questions`; - const passed = assignment?.passing != null ? session.score >= assignment.passing : null; + const conditions = result.conditions.stated; + const passed = result.outcome.metGoal; const summary = [ `Count It — Choose the Count`, assignment?.name ? `Assignment: ${assignment.name}` : "Practice session", @@ -561,6 +574,11 @@ export default function CountItApp() { // applied after mount so the server-rendered shell and the first client // render agree; the round it pins takes over immediately afterwards. const [assignment, setAssignment] = useState(null); + /* Which published sequence step a link named. NOT a setting — it records + which assignment this was and never reaches the generator. See + src/sequence-step.ts on why that separation is enforced rather than + assumed. */ + const [sequenceStep, setSequenceStep] = useState(null); const [linkError, setLinkError] = useState(null); const [studentId, setStudentId] = useState(""); const [finishedAt, setFinishedAt] = useState(null); @@ -575,6 +593,10 @@ export default function CountItApp() { // an effect is guarding against cascading renders, and an assignment sets // several pieces of state at once. const applyLink = window.setTimeout(() => { + /* Read before the assignment is validated: a link that names a step and + then fails validation is still evidence of which step was attempted, + and refusing the round does not make the marker untrue. */ + setSequenceStep(parseSequenceStep(window.location.search)); const result = parseAssignment(window.location.search); if (!result.ok) { setLinkError(result.error); @@ -805,6 +827,7 @@ export default function CountItApp() { showReference={showReference} personalBest={Math.max(personalBests[bestKey] ?? 0, session.status === "complete" ? session.score : 0)} assignment={assignment} + sequenceStep={sequenceStep} studentId={studentId} finishedAt={finishedAt} onStudentIdChange={setStudentId} diff --git a/public/praxis-capabilities.json b/public/praxis-capabilities.json index fb40d89..bfb6e4b 100644 --- a/public/praxis-capabilities.json +++ b/public/praxis-capabilities.json @@ -2,11 +2,11 @@ "schemaVersion": "1.0.0", "appId": "count-it", "title": "Count It", - "version": "2026-08-08.3", + "version": "2026-08-08.4", "launchUrl": "https://count-it.backwerdrhythmshop.com/", "launchUrlStatus": "Live. Cloudflare, custom domain.", - "integrationLevel": 1, - "integrationLevelRationale": "Accepts a configured assignment link and scores objectively, producing a capture-ready result card with a verification code. The result is app-local and never transmitted, so this is Level 1 until the universal result envelope is adopted.", + "integrationLevel": 2, + "integrationLevelRationale": "Accepts a configured assignment link, scores objectively, and emits the universal result envelope (praxis.result.v0_1) — the condition this manifest previously named for Level 2, now met. Transmission is NOT part of the claim and never has been: the envelope is a format, the card renders from it, and nothing leaves the device. Scale Trail claims Level 2 on the same terms.", "pathway": "rhythm-reading-and-counting", "supportedActivityTypes": [ "choose-the-count", @@ -71,8 +71,8 @@ ], "compatibleSkillDomains": [], "skillVocabularyStatus": "Not yet reconciled. Candidate ids exist in the Sequence 2 draft but the vocabulary is an owner decision, and an invented id would be a contract no one agreed to. Until it is settled this app emits no skill ids.", - "resultSchemaVersion": "app-local-1", - "resultSchemaStatus": "The result card is app-local: score, accuracy, the assignment's stated conditions, the teacher's pass mark, and a non-cryptographic verification code. It is not the universal result contract and is never transmitted. Migrating it is separate work.", + "resultSchemaVersion": "praxis.result.v0_1", + "resultSchemaStatus": "Adopted 2026-08-08, last of the three apps, after Scale Trail and Mallet Map. This app had no result object at all — the card was assembled inline — so the envelope was built rather than renamed. `skillReferences` is null and says so: the field is nullable across the family precisely so an app with no reconciled vocabulary can decline rather than invent ids. `activity.contentVersion` is null because questions are generated from the conditions and a seed, which already reproduce the round. In measure scope a miss is attributed to every cell in the measure, because the question is answered as a whole — the finest attribution the format allows, not a claim that all four were misread. Emitting the shape is not transmitting it: nothing leaves the device.", "supportDimensions": "The subdivision guide is a support, not a preference: an assignment pins it, and a gate counts the assignment's policy rather than the learner's own toggle. Question size (one beat or one measure) and rhythm vocabulary are the other two difficulty dimensions. There is no timer anywhere in this app, by design.", "accessibility": [ "keyboard-operation", diff --git a/src/capabilities.ts b/src/capabilities.ts index 4033de3..425a03c 100644 --- a/src/capabilities.ts +++ b/src/capabilities.ts @@ -24,7 +24,7 @@ import { LEVELS, RHYTHM_CELLS } from "./rhythm"; /* The build identifier, single-sourced here so the footer stamp, the manifest, and the README release line cannot disagree. The repo's release gate checks the README against this value appearing in app code. */ -export const COUNT_IT_BUILD = "2026-08-08.3"; +export const COUNT_IT_BUILD = "2026-08-08.4"; export const COUNT_IT_CAPABILITY_MANIFEST = { schemaVersion: "1.0.0", @@ -33,15 +33,18 @@ export const COUNT_IT_CAPABILITY_MANIFEST = { version: COUNT_IT_BUILD, launchUrl: "https://count-it.backwerdrhythmshop.com/", launchUrlStatus: "Live. Cloudflare, custom domain.", - /* Level 1: it scores objectively and builds a result card a student can - submit, but that result is app-local and never transmitted, so it does not - claim Level 2. Raising it means adopting the universal result envelope — - see doc 23, which lists this app's envelope as unreconciled. */ - integrationLevel: 1, + /* Level 2 as of 2026-08-08.4. The previous comment here said "raising it + means adopting the universal result envelope" — a criterion written down in + advance, and now met, so the level moves with it. Note what the level does + NOT assert: nothing is transmitted, by this app or by its siblings. Scale + Trail has claimed Level 2 on exactly these terms since before this. */ + integrationLevel: 2, integrationLevelRationale: - "Accepts a configured assignment link and scores objectively, producing a capture-ready " + - "result card with a verification code. The result is app-local and never transmitted, so " + - "this is Level 1 until the universal result envelope is adopted.", + "Accepts a configured assignment link, scores objectively, and emits the universal result " + + "envelope (praxis.result.v0_1) — the condition this manifest previously named for Level 2, " + + "now met. Transmission is NOT part of the claim and never has been: the envelope is a format, " + + "the card renders from it, and nothing leaves the device. Scale Trail claims Level 2 on the " + + "same terms.", pathway: "rhythm-reading-and-counting", supportedActivityTypes: ["choose-the-count", "practice-reading"], /* The URL parameters an assignment link may carry, exactly as the parser @@ -83,11 +86,17 @@ export const COUNT_IT_CAPABILITY_MANIFEST = { "Not yet reconciled. Candidate ids exist in the Sequence 2 draft but the vocabulary is an " + "owner decision, and an invented id would be a contract no one agreed to. Until it is " + "settled this app emits no skill ids.", - resultSchemaVersion: "app-local-1", + resultSchemaVersion: "praxis.result.v0_1", resultSchemaStatus: - "The result card is app-local: score, accuracy, the assignment's stated conditions, the " + - "teacher's pass mark, and a non-cryptographic verification code. It is not the universal " + - "result contract and is never transmitted. Migrating it is separate work.", + "Adopted 2026-08-08, last of the three apps, after Scale Trail and Mallet Map. This app had " + + "no result object at all — the card was assembled inline — so the envelope was built rather " + + "than renamed. `skillReferences` is null and says so: the field is nullable across the family " + + "precisely so an app with no reconciled vocabulary can decline rather than invent ids. " + + "`activity.contentVersion` is null because questions are generated from the conditions and a " + + "seed, which already reproduce the round. In measure scope a miss is attributed to every cell " + + "in the measure, because the question is answered as a whole — the finest attribution the " + + "format allows, not a claim that all four were misread. Emitting the shape is not " + + "transmitting it: nothing leaves the device.", supportDimensions: "The subdivision guide is a support, not a preference: an assignment pins it, and a gate " + "counts the assignment's policy rather than the learner's own toggle. Question size (one " + diff --git a/src/result.ts b/src/result.ts new file mode 100644 index 0000000..7c29efd --- /dev/null +++ b/src/result.ts @@ -0,0 +1,229 @@ +/* The universal result envelope: praxis.result.v0_1. + * + * This app had no result object at all. Scale Trail and Mallet Map each built a + * structured result and could rename fields into the new shape; here the card + * and the copyable summary were assembled inline from a session, so there was + * nothing to migrate — only something to build. That makes this the largest of + * the three migrations and also the cleanest, because nothing had to be + * preserved for compatibility's sake. + * + * The shape follows Scale Trail, which took it first because it was closest to + * it. Where this app genuinely lacks the data a field wants, it emits null and + * says why: a consumer cannot tell a plausible-looking guess from a real value, + * so an absence with a stated reason is worth more than a confident fiction. + * + * NOTHING HERE IS TRANSMITTED. The envelope is a format, not a protocol — the + * card renders from it, the summary is copied by hand, and no request leaves + * the device. Adopting the shape changed no privacy posture. + */ +import type { Assignment } from "./assignment"; +import { describeAssignment } from "./assignment"; +import { COUNT_IT_BUILD } from "./capabilities"; +import type { CountQuestion } from "./question/generator"; +import type { ChallengeSession } from "./question/session"; +import { type SequenceStep, sequenceStepId } from "./sequence-step"; +import { getLevel } from "./rhythm"; + +/** The contract this app now speaks, shared with Scale Trail and Mallet Map. */ +export const RESULT_SCHEMA_VERSION = "praxis.result.v0_1"; + +/* Named and versioned so a change in how an answer is judged is legible in the + evidence rather than silent. Bump when the judging changes, not the questions. */ +export const SCORING_RULE_VERSION = "rhythm-counting-v1"; + +export interface ErrorSummaryEntry { + /** A rhythm cell id from the published catalog. */ + readonly item: string; + readonly asked: number; + readonly wrong: number; +} + +export interface PraxisEvidenceResult { + readonly schemaVersion: typeof RESULT_SCHEMA_VERSION; + readonly app: { readonly id: "count-it"; readonly version: string }; + readonly activity: { + readonly type: "choose-the-count"; + readonly version: string; + readonly contentVersion: string | null; + readonly scoringRuleVersion: string; + }; + /** The conditions this round ran under, in this app's own terms. */ + readonly conditions: { + readonly scope: "beat" | "measure"; + readonly level: string; + readonly cells: readonly string[] | null; + readonly guide: "on" | "off" | null; + readonly countingSystem: string; + /** The teacher's pass mark. Reported, never enforced. */ + readonly passing: number | null; + readonly stated: string; + }; + readonly skillReferences: readonly string[] | null; + readonly assignmentReference: string | null; + readonly sequenceStep: SequenceStep | null; + readonly attemptReference: string; + readonly evidenceType: "A1_ANSWER_CORRECTNESS"; + readonly outcome: { + readonly score: number; + readonly possible: number; + readonly accuracy: number; + readonly completed: boolean; + /** Present when the link set a pass mark. Reported, never enforced. */ + readonly metGoal: boolean | null; + }; + readonly measured: readonly string[]; + readonly notMeasured: readonly string[]; + readonly validity: { readonly valid: boolean; readonly deviceReliabilityFlags: readonly string[] }; + readonly errorSummary: readonly ErrorSummaryEntry[]; + readonly settings: Readonly>; + readonly inputSource: readonly string[]; + readonly timestamp: string; + readonly recommendedNextActions: readonly string[]; +} + +/* One attempt, named without borrowing the seed. + * + * The seed is a randomization input and this family's guidance is to change it + * for a retake, so an attempt identified by it stops being a record of the step + * that was assigned. What goes in is what was assigned, what happened and when. + * + * Deliberately NOT the same string as the user-visible verification code. That + * code is a teacher-facing artifact with its own published format and its own + * stated limits ("a deterrent, not proof"); whether it generalizes across the + * three apps is an open decision, and quietly making the two identical here + * would answer it by accident. */ +function attemptReference(assigned: string, score: number, possible: number, timestamp: string): string { + const input = `count-it|${assigned}|${score}/${possible}|${timestamp}`; + let hash = 2166136261; + for (let index = 0; index < input.length; index += 1) { + hash ^= input.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return `CI-${(hash >>> 0).toString(36).toUpperCase()}`; +} + +/* Which rhythms this round actually asked about, and which were missed. + * + * Keyed by catalog cell id, which is the vocabulary a link is written against, + * so a report reads in the same terms the assignment was set in. + * + * One honest caveat, stated here because the data cannot state it: in MEASURE + * scope a question carries four cells and is answered as a whole, so a miss is + * attributed to every cell in that measure. It is not a claim that all four + * were misread — it is the finest attribution the question format allows, and + * pretending otherwise would invent per-cell evidence nobody collected. */ +function summarizeErrors(session: ChallengeSession): ErrorSummaryEntry[] { + const byQuestion = new Map(); + for (const question of session.questions) byQuestion.set(question.id, question); + + const tally = new Map(); + const order: string[] = []; + for (const response of session.responses) { + const question = byQuestion.get(response.questionId); + if (!question) continue; + for (const cell of question.prompt.cells) { + let entry = tally.get(cell.id); + if (!entry) { + entry = { asked: 0, wrong: 0 }; + tally.set(cell.id, entry); + order.push(cell.id); + } + entry.asked += 1; + if (!response.correct) entry.wrong += 1; + } + } + return order.map((id) => ({ item: id, asked: tally.get(id)!.asked, wrong: tally.get(id)!.wrong })); +} + +export function createPraxisEvidenceResult(options: { + session: ChallengeSession; + assignment: Assignment | null; + sequenceStep: SequenceStep | null; + level: string; + scope: "beat" | "measure"; + finishedAt: Date; +}): PraxisEvidenceResult { + const { session, assignment, sequenceStep, finishedAt } = options; + const possible = session.questions.length; + const score = session.score; + const timestamp = finishedAt.toISOString(); + const assigned = sequenceStep + ? sequenceStepId(sequenceStep) + : assignment?.name ?? "free-play"; + const errorSummary = summarizeErrors(session); + const missed = errorSummary.filter((entry) => entry.wrong > 0).map((entry) => entry.item); + + const scope = assignment?.scope ?? options.scope; + const level = assignment?.level ?? options.level; + + const settings: Record = {}; + if (assignment) { + if (assignment.name) settings.a = assignment.name; + if (!assignment.cells) settings.level = String(level).slice(-1); + settings.scope = assignment.scope; + if (assignment.cells) settings.cells = assignment.cells.join(","); + if (assignment.guide) settings.guide = assignment.guide; + if (assignment.count !== null) settings.n = String(assignment.count); + if (assignment.passing !== null) settings.pass = String(assignment.passing); + if (assignment.seed) settings.seed = assignment.seed; + } + + return Object.freeze({ + schemaVersion: RESULT_SCHEMA_VERSION, + app: { id: "count-it" as const, version: COUNT_IT_BUILD }, + activity: { + type: "choose-the-count" as const, + version: "1.0.0", + /* Null, and stated rather than synthesized. This app does not version its + question content: questions are generated from the conditions below and + a seed, so the conditions plus `settings.seed` already reproduce the + round exactly. A made-up string would read as a content revision that + never happened. */ + contentVersion: null, + scoringRuleVersion: SCORING_RULE_VERSION, + }, + conditions: { + scope, + level, + cells: assignment?.cells ?? null, + guide: assignment?.guide ?? null, + countingSystem: assignment?.system ?? "standard", + passing: assignment?.passing ?? null, + /* The same sentence the card shows, so the human record and the machine + record cannot describe different rounds. */ + stated: assignment + ? describeAssignment(assignment) + : `${getLevel(level as Parameters[0]).shortName} · ${scope === "beat" ? "one beat" : "one measure"}`, + }, + /* Null, deliberately, and the manifest says the same thing. This app has no + reconciled Praxis skill vocabulary: candidate ids exist in the Sequence 2 + draft but the vocabulary is an owner decision, and an invented id would be + a contract nobody agreed to — indistinguishable, to a consumer, from a + real one. The field is nullable across the family precisely so this can be + said rather than faked. */ + skillReferences: null, + assignmentReference: assignment?.name ?? null, + sequenceStep, + attemptReference: attemptReference(assigned, score, possible, timestamp), + evidenceType: "A1_ANSWER_CORRECTNESS" as const, + outcome: { + score, + possible, + accuracy: possible === 0 ? 0 : Math.round((score / possible) * 100), + completed: session.responses.length === possible, + metGoal: assignment?.passing != null ? score >= assignment.passing : null, + }, + measured: ["rhythm notation reading", "counting-syllable selection"], + /* The boundary, on the evidence itself rather than only in the manifest. + There is no timer anywhere in this app, by design. */ + notMeasured: ["live playing", "tone quality", "sticking or hand use", "tempo", "speed", "audiation"], + validity: { valid: true, deviceReliabilityFlags: [] }, + errorSummary, + settings, + inputSource: ["touch", "mouse", "computer-keyboard"], + timestamp, + recommendedNextActions: missed.length + ? [`Review these rhythms before the retake: ${missed.join(", ")}.`] + : ["Repeat the same round after a delay to provide retention evidence."], + }); +} diff --git a/src/sequence-step.ts b/src/sequence-step.ts new file mode 100644 index 0000000..4692533 --- /dev/null +++ b/src/sequence-step.ts @@ -0,0 +1,48 @@ +/* Which published Teaching Sequence step this round is, if any. + * + * The shop site publishes a sequence manifest and each assignment link carries + * `seq` and `step` to name the step it opens: + * + * ?seq=counting-rhythms&step=1&scope=beat&cells=quarter,eighths&… + * + * THESE ARE NOT SETTINGS, which is why they are here and not in the assignment + * parser. A setting changes the round; these change nothing — the same link + * with them removed runs the identical exercise, and the shop site's contract + * check fails if any app declares them as configurable settings. They record + * WHICH ASSIGNMENT this was, which belongs on the evidence for the same reason + * a name belongs on a paper. + * + * Read from the link rather than derived from the seed on purpose: the seed is + * a randomization input and changes for a retake, so a step identified by it + * would move the moment a teacher reseeded, orphaning its own history. + */ + +export interface SequenceStep { + /** The sequence's stable id, e.g. "counting-rhythms". */ + readonly seq: string; + /** Position in the DESIGNED sequence, so filling a gap later renumbers nothing. */ + readonly step: number; +} + +/* A published sequence id is a slug. Anything else is a hand-typed link or an + unrelated parameter that happens to be called `seq`, and stamping it onto + evidence would assert an assignment that does not exist. */ +const SEQUENCE_ID = /^[a-z][a-z0-9-]{0,63}$/; + +export function parseSequenceStep(search: string): SequenceStep | null { + const params = new URLSearchParams(search); + const seq = (params.get("seq") ?? "").trim(); + const raw = (params.get("step") ?? "").trim(); + if (!SEQUENCE_ID.test(seq)) return null; + /* Both or neither. A `seq` with no `step` names a sequence but not a place in + it, and recording half an identity invites a consumer to guess the rest. */ + if (!/^\d{1,3}$/.test(raw)) return null; + const step = Number(raw); + if (step < 1) return null; + return Object.freeze({ seq, step }); +} + +/** The step's stable id, the spelling the sequence manifest publishes. */ +export function sequenceStepId(step: SequenceStep): string { + return `${step.seq}#${step.step}`; +} diff --git a/tests/capabilities.test.ts b/tests/capabilities.test.ts index bf5bbd0..03eef3e 100644 --- a/tests/capabilities.test.ts +++ b/tests/capabilities.test.ts @@ -55,7 +55,15 @@ describe("the published capability manifest", () => { }); it("does not claim evidence or abilities this app lacks", () => { - expect(COUNT_IT_CAPABILITY_MANIFEST.integrationLevel).toBe(1); + /* Was 1. The manifest itself named the condition — "Level 1 until the + universal result envelope is adopted" — and 2026-08-08.4 adopted it, so + the level moves with the criterion that was written down in advance. + What Level 2 does NOT assert is transmission: nothing leaves the device + here or in either sibling, and Scale Trail has claimed 2 on the same + terms throughout. */ + expect(COUNT_IT_CAPABILITY_MANIFEST.integrationLevel).toBe(2); + expect(COUNT_IT_CAPABILITY_MANIFEST.integrationLevelRationale).toMatch(/nothing leaves the device/i); + expect(COUNT_IT_CAPABILITY_MANIFEST.resultSchemaVersion).toBe("praxis.result.v0_1"); const limitations = COUNT_IT_CAPABILITY_MANIFEST.limitations.join(" "); expect(limitations).toMatch(/no audio, no microphone, and no tempo engine/i); expect(limitations).toMatch(/does not measure live performance/i); diff --git a/tests/result.test.ts b/tests/result.test.ts new file mode 100644 index 0000000..8bf02fc --- /dev/null +++ b/tests/result.test.ts @@ -0,0 +1,174 @@ +/* praxis.result.v0_1, the envelope this app had never had (doc 24). + * + * Scale Trail and Mallet Board each had a structured result to rename. Here the + * card assembled its facts inline, so there was nothing to migrate and nothing + * to preserve — which makes these tests the only thing standing between the + * envelope and quiet drift from the card it is supposed to describe. + */ +import { describe, expect, it } from "vitest"; +import { createPraxisEvidenceResult, RESULT_SCHEMA_VERSION } from "../src/result"; +import { parseSequenceStep, sequenceStepId } from "../src/sequence-step"; +import { parseAssignment } from "../src/assignment"; +import { generateQuestions } from "../src/question/generator"; +import { advanceSession, answerSession, createSession, type ChallengeSession } from "../src/question/session"; +import { COUNT_IT_BUILD } from "../src/capabilities"; + +function playedSession(options: { correct: boolean[]; seed?: number }): ChallengeSession { + const questions = generateQuestions({ + level: "level-1", scope: "beat", count: options.correct.length, seed: options.seed ?? 4242, + }); + let session = createSession(questions); + for (const correct of options.correct) { + const current = session.questions[session.currentIndex]; + const choice = correct + ? current.choices.find((candidate) => candidate.isCorrect)! + : current.choices.find((candidate) => !candidate.isCorrect)!; + session = advanceSession(answerSession(session, choice.id)); + } + return session; +} + +function receipt(over: { + correct?: boolean[]; + search?: string; + finishedAt?: Date; +} = {}) { + const session = playedSession({ correct: over.correct ?? [true, true, false] }); + const search = over.search ?? ""; + const parsed = search ? parseAssignment(search) : null; + const assignment = parsed && parsed.ok ? parsed.assignment : null; + return createPraxisEvidenceResult({ + session, + assignment, + sequenceStep: parseSequenceStep(search), + level: assignment?.level ?? "level-1", + scope: assignment?.scope ?? "beat", + finishedAt: over.finishedAt ?? new Date("2026-08-08T00:00:00.000Z"), + }); +} + +describe("the result envelope", () => { + it("names the universal contract and this build", () => { + const result = receipt(); + expect(RESULT_SCHEMA_VERSION).toBe("praxis.result.v0_1"); + expect(result.schemaVersion).toBe("praxis.result.v0_1"); + expect(result.app).toEqual({ id: "count-it", version: COUNT_IT_BUILD }); + expect(result.activity.scoringRuleVersion).toBe("rhythm-counting-v1"); + }); + + it("declines to claim a skill vocabulary rather than inventing one", () => { + /* The field is nullable across the family for exactly this app: candidate + ids exist in the Sequence 2 draft, but the vocabulary is an owner + decision and an invented id is indistinguishable, to a consumer, from a + real one. */ + expect(receipt().skillReferences).toBeNull(); + }); + + it("states the boundary on the evidence, not only in the manifest", () => { + const result = receipt(); + expect(result.notMeasured).toContain("speed"); + expect(result.notMeasured).toContain("live playing"); + expect(result.evidenceType).toBe("A1_ANSWER_CORRECTNESS"); + expect(result.validity.valid).toBe(true); + expect(result).not.toHaveProperty("mastery"); + expect(result).not.toHaveProperty("grade"); + }); + + it("reports the goal without enforcing it", () => { + const search = "?scope=beat&cells=quarter,eighths&n=3&pass=3&seed=cr1"; + const met = receipt({ correct: [true, true, true], search }); + const missedIt = receipt({ correct: [true, true, false], search }); + expect(met.outcome.metGoal).toBe(true); + expect(missedIt.outcome.metGoal).toBe(false); + /* Reported, never enforced: a round below the mark still completes and + still produces evidence. */ + expect(missedIt.outcome.completed).toBe(true); + expect(missedIt.conditions.passing).toBe(3); + /* Free play has no goal, and says null rather than false. */ + expect(receipt().outcome.metGoal).toBeNull(); + }); + + it("records which published step it was", () => { + const result = receipt({ search: "?seq=counting-rhythms&step=1&scope=beat&cells=quarter,eighths&seed=cr1" }); + expect(result.sequenceStep).toEqual({ seq: "counting-rhythms", step: 1 }); + expect(sequenceStepId(result.sequenceStep!)).toBe("counting-rhythms#1"); + expect(receipt().sequenceStep).toBeNull(); + }); + + /* THE RULE THE RECONCILIATION TURNS ON. The seed is a randomization input and + this family's guidance is to change it for a retake, so an attempt + identified by it stops being a record of the step that was assigned. */ + it("does not move the attempt reference when only the seed changes", () => { + const base = "?seq=counting-rhythms&step=1&scope=beat&cells=quarter,eighths&n=3"; + const first = receipt({ search: `${base}&seed=cr1-quarters-pairs` }); + const retake = receipt({ search: `${base}&seed=cr1-quarters-pairs-again` }); + expect(first.attemptReference).toBe(retake.attemptReference); + expect(first.attemptReference).not.toMatch(/quarters/); + }); + + it("still separates two genuinely different attempts", () => { + const search = "?seq=counting-rhythms&step=1&scope=beat&cells=quarter,eighths&n=3&seed=cr1"; + expect(receipt({ search, finishedAt: new Date("2026-08-08T00:00:00.000Z") }).attemptReference) + .not.toBe(receipt({ search, finishedAt: new Date("2026-08-09T00:00:00.000Z") }).attemptReference); + }); + + it("keeps the verification code distinct from the attempt reference", () => { + /* The code is teacher-facing, with its own published format and its own + stated limits. Whether it generalizes across the three apps is an open + decision; making the two identical would answer it by accident. */ + expect(receipt().attemptReference).toMatch(/^CI-[0-9A-Z]+$/); + expect(receipt().attemptReference).not.toMatch(/^BRS-CI-/); + }); + + it("keeps the human sentence and the machine record describing one round", () => { + const result = receipt({ search: "?scope=beat&cells=quarter,eighths&guide=on&n=3&pass=2&seed=cr1" }); + /* `stated` is the exact string the card prints, carried on the evidence, so + the two cannot describe different rounds. */ + expect(result.conditions.stated).toContain("2 rhythms"); + expect(result.conditions.cells).toEqual(["quarter", "eighths"]); + expect(result.conditions.guide).toBe("on"); + expect(result.settings.cells).toBe("quarter,eighths"); + expect(result.settings.seed).toBe("cr1"); + }); + + it("reports which rhythms were asked and which were missed", () => { + const result = receipt({ correct: [true, false, false] }); + const asked = result.errorSummary.reduce((total, entry) => total + entry.asked, 0); + expect(asked).toBe(3); + expect(result.errorSummary.reduce((total, entry) => total + entry.wrong, 0)).toBe(2); + expect(result.recommendedNextActions[0]).toMatch(/Review these rhythms/); + /* A clean round recommends retention rather than remediation. */ + expect(receipt({ correct: [true, true, true] }).recommendedNextActions[0]).toMatch(/retention/); + }); + + it("does not version content it does not version", () => { + /* Null, and said out loud: questions come from the conditions plus a seed, + which already reproduce the round. A synthesized string would read as a + content revision that never happened. */ + expect(receipt().activity.contentVersion).toBeNull(); + }); +}); + +describe("the sequence identity markers", () => { + it("needs both halves, and a published slug", () => { + expect(parseSequenceStep("?seq=counting-rhythms&step=2")).toEqual({ seq: "counting-rhythms", step: 2 }); + expect(parseSequenceStep("?seq=counting-rhythms")).toBeNull(); + expect(parseSequenceStep("?step=2")).toBeNull(); + expect(parseSequenceStep("?seq=Counting-Rhythms&step=2")).toBeNull(); + expect(parseSequenceStep("?seq=counting-rhythms&step=0")).toBeNull(); + }); + + it("changes nothing about the round", () => { + /* The claim that makes them inert, checked rather than asserted. The shop + site's contract check fails if this app ever declares them as settings; a + marker that started changing the round would mean a Module pointing at + step 1 points at something that acts. */ + const bare = "?scope=beat&cells=quarter,eighths&guide=on&n=12&pass=10&seed=cr1-quarters-pairs"; + const marked = `?seq=counting-rhythms&step=1&${bare.slice(1)}`; + const a = parseAssignment(bare); + const b = parseAssignment(marked); + expect(a.ok && b.ok).toBe(true); + expect(b.ok && a.ok ? b.assignment : null).toEqual(a.ok ? a.assignment : null); + expect(b.ok && a.ok ? [...b.locked] : null).toEqual(a.ok ? [...a.locked] : null); + }); +});