From ee06de71813d42317fcd867015261803571e4877 Mon Sep 17 00:00:00 2001 From: ABCrimson <231791317+ABCrimson@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:35:54 -0500 Subject: [PATCH 1/5] feat(math-engine): seed-deterministic problem generation with CAS-graded answers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New string-seeded RNG (xmur3 + mulberry32); seeds minted via crypto.getRandomValues — no Math.random anywhere in generation - templateEngine.generate(id, seed) is now pure and seed-required: the RNG is a local stream threaded through parameter generation (the mutable Math.random field and old LCG are gone) - ProblemInstance carries its seed (URL round-tripping) and an optional machine-gradable answer computed at generate() time from a new per-template canonical() spec: exact roots via the closed-form solver, expressions for CAS-equivalence grading, numeric values with per-answer tolerance, and labeled assignments for systems - canonical() added to 22 of 24 templates (prime-factorization stays textual; linear-inequality answers carry an operator the equation solver cannot support, so it deliberately has no graded answer) - Fix latent system-linear-2x2 bug: the det != 0 constraint sat on c2, which the determinant does not involve — retries could never repair a singular draw; moved to b2 where at most one value in range is bad - generateBatch(id, count, baseSeed?) derives reproducible per-item seeds as `${baseSeed}:${i}` - './problems/templates' and './equivalence' subpath exports - Tests: seeded determinism across all 24 templates, 100-seed constraint sweeps, and back-substitution of every graded root into the ORIGINAL equation (|LHS-RHS| < 1e-6) Co-Authored-By: Claude Fable 5 --- packages/math-engine/package.json | 8 + .../problems/templates/algebra-templates.ts | 70 ++++- .../problems/templates/calculus-templates.ts | 40 +++ .../problems/templates/geometry-templates.ts | 21 ++ .../src/problems/templates/index.ts | 1 + .../templates/number-theory-templates.ts | 8 + .../templates/probability-templates.ts | 12 + .../src/problems/templates/seeded-rng.ts | 72 +++++ .../templates/template-engine.test.ts | 247 ++++++++++++++++++ .../src/problems/templates/template-engine.ts | 175 ++++++++++--- 10 files changed, 610 insertions(+), 44 deletions(-) create mode 100644 packages/math-engine/src/problems/templates/seeded-rng.ts create mode 100644 packages/math-engine/src/problems/templates/template-engine.test.ts diff --git a/packages/math-engine/package.json b/packages/math-engine/package.json index 4200ae87..f79d73ba 100644 --- a/packages/math-engine/package.json +++ b/packages/math-engine/package.json @@ -59,6 +59,14 @@ "types": "./src/problems/index.ts", "default": "./src/problems/index.ts" }, + "./problems/templates": { + "types": "./src/problems/templates/index.ts", + "default": "./src/problems/templates/index.ts" + }, + "./equivalence": { + "types": "./src/equivalence/index.ts", + "default": "./src/equivalence/index.ts" + }, "./prover": { "types": "./src/prover/index.ts", "default": "./src/prover/index.ts" diff --git a/packages/math-engine/src/problems/templates/algebra-templates.ts b/packages/math-engine/src/problems/templates/algebra-templates.ts index b19647a6..1e726393 100644 --- a/packages/math-engine/src/problems/templates/algebra-templates.ts +++ b/packages/math-engine/src/problems/templates/algebra-templates.ts @@ -72,6 +72,10 @@ export const linearEquationTemplate = createTemplate({ tags: ['linear', 'equations', 'solving'], prerequisites: ['arithmetic', 'variables'], learningObjectives: ['Solve linear equations', 'Apply inverse operations'], + canonical: (params) => { + const { a, b, c } = narrow<{ a: number; b: number; c: number }>(params); + return { kind: 'solutions', equation: `${a}*x + ${b} = ${c}`, variable: 'x' }; + }, }); /** @@ -154,6 +158,11 @@ export const quadraticEquationTemplate = createTemplate({ tags: ['quadratic', 'equations', 'formula'], prerequisites: ['algebra', 'square-roots'], learningObjectives: ['Apply quadratic formula', 'Calculate discriminant'], + canonical: (params) => { + const { a, b, c } = narrow<{ a: number; b: number; c: number }>(params); + // The discriminant >= 0 constraint guarantees real roots + return { kind: 'solutions', equation: `${a}*x^2 + ${b}*x + ${c} = 0`, variable: 'x' }; + }, }); /** @@ -171,19 +180,21 @@ export const systemLinearTemplate = createTemplate({ { name: 'b1', type: 'integer', min: 1, max: 5 }, { name: 'c1', type: 'integer', min: 1, max: 20 }, { name: 'a2', type: 'integer', min: 1, max: 5 }, - { name: 'b2', type: 'integer', min: 1, max: 5 }, { - name: 'c2', + name: 'b2', type: 'integer', min: 1, - max: 20, - constraint: (_c2, params) => { - // Ensure unique solution (determinant != 0) - const det = - (params['a1'] ?? 0) * (params['b2'] ?? 0) - (params['a2'] ?? 0) * (params['b1'] ?? 0); + max: 5, + // Ensure unique solution (determinant != 0). The constraint must sit + // on b2 — the last parameter the determinant depends on — so retrying + // it can actually repair a singular draw (at most one b2 in range is + // bad for any fixed a1, b1, a2). + constraint: (b2, params) => { + const det = (params['a1'] ?? 0) * b2 - (params['a2'] ?? 0) * (params['b1'] ?? 0); return det !== 0; }, }, + { name: 'c2', type: 'integer', min: 1, max: 20 }, ], solution: (params) => { const { a1, b1, c1, a2, b2, c2 } = narrow<{ @@ -227,6 +238,21 @@ export const systemLinearTemplate = createTemplate({ tags: ['systems', 'linear-equations', 'simultaneous'], prerequisites: ['linear-equations', 'substitution'], learningObjectives: ['Solve systems of equations', "Apply Cramer's rule"], + canonical: (params) => { + const { a1, b1, c1, a2, b2, c2 } = narrow<{ + a1: number; + b1: number; + c1: number; + a2: number; + b2: number; + c2: number; + }>(params); + // Same Cramer's-rule arithmetic as the solution generator — exact values + const det = a1 * b2 - a2 * b1; + const x = (c1 * b2 - c2 * b1) / det; + const y = (a1 * c2 - a2 * c1) / det; + return { kind: 'assignments', variables: ['x', 'y'], values: [x, y] }; + }, }); /** @@ -308,6 +334,12 @@ export const factorizationTemplate = createTemplate({ tags: ['factoring', 'polynomials', 'quadratics'], prerequisites: ['polynomials', 'multiplication'], learningObjectives: ['Factor quadratic expressions'], + canonical: (params) => { + const { b, c } = narrow<{ b: number; c: number }>(params); + // Equivalence-graded: any correct factoring of x^2 + bx + c is + // mathematically equivalent to the original polynomial. + return { kind: 'expression', expression: `x^2 + ${b}*x + ${c}`, variable: 'x' }; + }, }); /** @@ -430,6 +462,16 @@ export const absoluteValueTemplate = createTemplate({ tags: ['absolute-value', 'equations'], prerequisites: ['absolute-value-concept', 'linear-equations'], learningObjectives: ['Solve absolute value equations', 'Handle multiple cases'], + canonical: (params) => { + const { a, b } = narrow<{ a: number; b: number }>(params); + // |x + a| = b (b >= 1 > 0) is exactly the quadratic (x + a)^2 = b^2, + // expanded here so the closed-form quadratic solver applies. + return { + kind: 'solutions', + equation: `x^2 + ${2 * a}*x + ${a * a - b * b} = 0`, + variable: 'x', + }; + }, }); /** @@ -476,6 +518,12 @@ export const radicalEquationTemplate = createTemplate({ tags: ['radicals', 'equations', 'square-roots'], prerequisites: ['radicals', 'exponents'], learningObjectives: ['Solve radical equations', 'Check for extraneous solutions'], + canonical: (params) => { + const { a, b } = narrow<{ a: number; b: number }>(params); + // sqrt(x + a) = b with b >= 1 has the unique non-extraneous solution + // of the squared linear equation x + a = b^2. + return { kind: 'solutions', equation: `x + ${a} = ${b * b}`, variable: 'x' }; + }, }); /** @@ -536,6 +584,14 @@ export const rationalSimplificationTemplate = createTemplate({ tags: ['rational-expressions', 'simplification', 'fractions'], prerequisites: ['fractions', 'factoring'], learningObjectives: ['Simplify rational expressions', 'Find common factors'], + canonical: (params) => { + const { a, b, c, d } = narrow<{ a: number; b: number; c: number; d: number }>(params); + return { + kind: 'expression', + expression: `(${a}*x + ${b})/(${c}*x + ${d})`, + variable: 'x', + }; + }, }); /** diff --git a/packages/math-engine/src/problems/templates/calculus-templates.ts b/packages/math-engine/src/problems/templates/calculus-templates.ts index 1634e86d..19c4aa62 100644 --- a/packages/math-engine/src/problems/templates/calculus-templates.ts +++ b/packages/math-engine/src/problems/templates/calculus-templates.ts @@ -64,6 +64,10 @@ export const derivativePowerRuleTemplate = createTemplate({ tags: ['derivatives', 'power-rule', 'calculus'], prerequisites: ['exponents', 'functions'], learningObjectives: ['Apply power rule for derivatives'], + canonical: (params) => { + const { a, n } = narrow<{ a: number; n: number }>(params); + return { kind: 'expression', expression: `${a * n}*x^${n - 1}`, variable: 'x' }; + }, }); /** @@ -110,6 +114,15 @@ export const derivativeProductRuleTemplate = createTemplate({ tags: ['derivatives', 'product-rule'], prerequisites: ['derivatives', 'power-rule'], learningObjectives: ['Apply product rule'], + canonical: (params) => { + const { a, b, c, d } = narrow<{ a: number; b: number; c: number; d: number }>(params); + // d/dx[(ax + b)(cx + d)] = 2ac·x + (ad + bc) + return { + kind: 'expression', + expression: `${2 * a * c}*x + ${a * d + b * c}`, + variable: 'x', + }; + }, }); /** @@ -152,6 +165,14 @@ export const derivativeChainRuleTemplate = createTemplate({ tags: ['derivatives', 'chain-rule'], prerequisites: ['derivatives', 'power-rule'], learningObjectives: ['Apply chain rule'], + canonical: (params) => { + const { a, b, n } = narrow<{ a: number; b: number; n: number }>(params); + return { + kind: 'expression', + expression: `${n * a}*(${a}*x + ${b})^${n - 1}`, + variable: 'x', + }; + }, }); /** @@ -204,6 +225,16 @@ export const integralPowerRuleTemplate = createTemplate({ tags: ['integrals', 'power-rule', 'antiderivatives'], prerequisites: ['derivatives', 'exponents'], learningObjectives: ['Apply power rule for integration'], + canonical: (params) => { + const { a, n } = narrow<{ a: number; n: number }>(params); + // Antiderivative without +C — the grader strips a trailing "+ C" + // from student input before checking equivalence. + return { + kind: 'expression', + expression: `(${a}/${n + 1})*x^${n + 1}`, + variable: 'x', + }; + }, }); /** @@ -255,6 +286,11 @@ export const definiteIntegralTemplate = createTemplate({ tags: ['integrals', 'definite-integrals', 'ftc'], prerequisites: ['integrals', 'ftc'], learningObjectives: ['Evaluate definite integrals', 'Apply FTC'], + canonical: (params) => { + const { a, b, c, n } = narrow<{ a: number; b: number; c: number; n: number }>(params); + const newN = n + 1; + return { kind: 'number', value: (c * b ** newN) / newN - (c * a ** newN) / newN }; + }, }); /** @@ -290,6 +326,10 @@ export const limitBasicTemplate = createTemplate({ tags: ['limits', 'continuity'], prerequisites: ['functions', 'continuity'], learningObjectives: ['Evaluate limits by substitution'], + canonical: (params) => { + const { a, b, c } = narrow<{ a: number; b: number; c: number }>(params); + return { kind: 'number', value: b * a + c }; + }, }); /** diff --git a/packages/math-engine/src/problems/templates/geometry-templates.ts b/packages/math-engine/src/problems/templates/geometry-templates.ts index 74c25bbb..68e3952b 100644 --- a/packages/math-engine/src/problems/templates/geometry-templates.ts +++ b/packages/math-engine/src/problems/templates/geometry-templates.ts @@ -52,6 +52,11 @@ export const pythagoreanTemplate = createTemplate({ tags: ['pythagorean', 'right-triangles', 'geometry'], prerequisites: ['squares', 'square-roots'], learningObjectives: ['Apply Pythagorean theorem'], + canonical: (params) => { + const { a, b } = narrow<{ a: number; b: number }>(params); + // Relative tolerance so rounded decimal answers (e.g. 5.83) pass + return { kind: 'number', value: Math.sqrt(a * a + b * b), tolerance: 1e-3 }; + }, }); /** @@ -95,6 +100,12 @@ export const circleAreaTemplate = createTemplate({ tags: ['circles', 'area', 'geometry'], prerequisites: ['pi', 'exponents'], learningObjectives: ['Calculate circle area'], + canonical: (params) => { + const { r } = narrow<{ r: number }>(params); + // π-answer: graded numerically with relative tolerance; symbolic + // input like "16*pi" evaluates exactly, decimals like 50.27 also pass + return { kind: 'number', value: Math.PI * r * r, tolerance: 1e-3 }; + }, }); /** @@ -129,6 +140,10 @@ export const cylinderVolumeTemplate = createTemplate({ tags: ['cylinders', 'volume', '3d-geometry'], prerequisites: ['circles', 'area', 'volume'], learningObjectives: ['Calculate cylinder volume'], + canonical: (params) => { + const { r, h } = narrow<{ r: number; h: number }>(params); + return { kind: 'number', value: Math.PI * r * r * h, tolerance: 1e-3 }; + }, }); /** @@ -178,6 +193,12 @@ export const distanceFormulaTemplate = createTemplate({ tags: ['distance', 'coordinate-geometry'], prerequisites: ['pythagorean-theorem', 'coordinates'], learningObjectives: ['Apply distance formula'], + canonical: (params) => { + const { x1, y1, x2, y2 } = narrow<{ x1: number; y1: number; x2: number; y2: number }>(params); + const dx = x2 - x1; + const dy = y2 - y1; + return { kind: 'number', value: Math.sqrt(dx * dx + dy * dy), tolerance: 1e-3 }; + }, }); /** diff --git a/packages/math-engine/src/problems/templates/index.ts b/packages/math-engine/src/problems/templates/index.ts index 1210af00..ac403ce7 100644 --- a/packages/math-engine/src/problems/templates/index.ts +++ b/packages/math-engine/src/problems/templates/index.ts @@ -9,6 +9,7 @@ export * from './calculus-templates'; export * from './geometry-templates'; export * from './number-theory-templates'; export * from './probability-templates'; +export * from './seeded-rng'; export * from './template-engine'; import { algebraTemplates } from './algebra-templates'; diff --git a/packages/math-engine/src/problems/templates/number-theory-templates.ts b/packages/math-engine/src/problems/templates/number-theory-templates.ts index 186e043f..81d659ae 100644 --- a/packages/math-engine/src/problems/templates/number-theory-templates.ts +++ b/packages/math-engine/src/problems/templates/number-theory-templates.ts @@ -74,6 +74,10 @@ export const gcdTemplate = createTemplate({ tags: ['gcd', 'euclidean-algorithm'], prerequisites: ['division', 'remainders'], learningObjectives: ['Calculate GCD'], + canonical: (params) => { + const { a, b } = narrow<{ a: number; b: number }>(params); + return { kind: 'number', value: gcd(a, b) }; + }, }); /** @@ -112,6 +116,10 @@ export const modularArithmeticTemplate = createTemplate({ tags: ['modular-arithmetic', 'congruences'], prerequisites: ['division', 'remainders'], learningObjectives: ['Perform modular arithmetic'], + canonical: (params) => { + const { a, m } = narrow<{ a: number; m: number }>(params); + return { kind: 'number', value: a % m }; + }, }); /** diff --git a/packages/math-engine/src/problems/templates/probability-templates.ts b/packages/math-engine/src/problems/templates/probability-templates.ts index a99dc41e..dc0014d5 100644 --- a/packages/math-engine/src/problems/templates/probability-templates.ts +++ b/packages/math-engine/src/problems/templates/probability-templates.ts @@ -56,6 +56,10 @@ export const basicProbabilityTemplate = createTemplate({ tags: ['probability', 'basic'], prerequisites: ['fractions', 'counting'], learningObjectives: ['Calculate basic probability'], + canonical: (params) => { + const { red, blue } = narrow<{ red: number; blue: number }>(params); + return { kind: 'number', value: red / (red + blue) }; + }, }); /** @@ -122,6 +126,10 @@ export const combinationsTemplate = createTemplate({ tags: ['combinations', 'combinatorics'], prerequisites: ['factorials'], learningObjectives: ['Calculate combinations'], + canonical: (params) => { + const { n, r } = narrow<{ n: number; r: number }>(params); + return { kind: 'number', value: combinations(n, r) }; + }, }); /** @@ -175,6 +183,10 @@ export const permutationsTemplate = createTemplate({ tags: ['permutations', 'combinatorics'], prerequisites: ['factorials'], learningObjectives: ['Calculate permutations'], + canonical: (params) => { + const { n, r } = narrow<{ n: number; r: number }>(params); + return { kind: 'number', value: permutations(n, r) }; + }, }); /** diff --git a/packages/math-engine/src/problems/templates/seeded-rng.ts b/packages/math-engine/src/problems/templates/seeded-rng.ts new file mode 100644 index 00000000..ee80a493 --- /dev/null +++ b/packages/math-engine/src/problems/templates/seeded-rng.ts @@ -0,0 +1,72 @@ +/** + * Deterministic string-seeded RNG for reproducible problem generation. + * + * - `createSeededRandom(seed)` — xmur3 string hash feeding a mulberry32 + * stream: the same seed string always yields the identical sequence. + * - `randomSeedString()` — mints a fresh shareable seed using the Web + * Crypto API (`globalThis.crypto.getRandomValues`, available in both + * Node 24+ and every browser). Math.random is deliberately never used. + */ + +/** + * xmur3 string hash. Produces a 32-bit hash generator from an arbitrary + * string; successive calls yield further decorrelated 32-bit values, + * which makes it a good seeder for mulberry32. + */ +function xmur3(str: string): () => number { + let h = 1779033703 ^ str.length; + for (let i = 0; i < str.length; i++) { + h = Math.imul(h ^ str.charCodeAt(i), 3432918353); + h = (h << 13) | (h >>> 19); + } + return () => { + h = Math.imul(h ^ (h >>> 16), 2246822507); + h = Math.imul(h ^ (h >>> 13), 3266489909); + h ^= h >>> 16; + return h >>> 0; + }; +} + +/** + * mulberry32 PRNG. Fast, high-quality 32-bit generator returning floats + * in [0, 1). + */ +function mulberry32(a: number): () => number { + let state = a >>> 0; + return () => { + state = (state + 0x6d2b79f5) | 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * Create a deterministic random stream from a seed string. + * + * @example + * const rng = createSeededRandom('abc123'); + * rng(); // always the same first value for 'abc123' + */ +export function createSeededRandom(seed: string): () => number { + const hash = xmur3(seed); + return mulberry32(hash()); +} + +/** Charset for minted seeds: unambiguous lowercase base-32 (no 0/1/l/o). */ +const SEED_CHARSET = 'abcdefghijkmnpqrstuvwxyz23456789'; + +/** + * Mint a random seed string using the platform CSPRNG. + * 32-character alphabet means each byte maps bias-free via `& 31`. + */ +export function randomSeedString(length = 8): string { + const bytes = new Uint8Array(length); + globalThis.crypto.getRandomValues(bytes); + let out = ''; + for (const byte of bytes) { + out += SEED_CHARSET[byte & 31]; + } + return out; +} diff --git a/packages/math-engine/src/problems/templates/template-engine.test.ts b/packages/math-engine/src/problems/templates/template-engine.test.ts new file mode 100644 index 00000000..66ff07b7 --- /dev/null +++ b/packages/math-engine/src/problems/templates/template-engine.test.ts @@ -0,0 +1,247 @@ +/** + * Template engine tests: seeded determinism, constraint satisfaction, + * and solver-exactness of CAS-computed graded answers. + */ + +import { beforeAll, describe, expect, it } from 'vitest'; +import { evaluate } from '../../parser'; +import { allTemplates, registerAllTemplates } from './index'; +import { createSeededRandom, randomSeedString } from './seeded-rng'; +import { narrow, templateEngine } from './template-engine'; + +beforeAll(() => { + registerAllTemplates(); +}); + +const templateIds = allTemplates.map((t) => t.id); +const SWEEP_SEEDS = Array.from({ length: 100 }, (_, i) => `sweep-${i}`); + +describe('seeded-rng', () => { + it('produces an identical sequence for the same seed', () => { + const a = createSeededRandom('seed-1'); + const b = createSeededRandom('seed-1'); + const seqA = Array.from({ length: 50 }, () => a()); + const seqB = Array.from({ length: 50 }, () => b()); + expect(seqA).toEqual(seqB); + }); + + it('produces different sequences for different seeds', () => { + const a = Array.from({ length: 10 }, createSeededRandom('a')); + const b = Array.from({ length: 10 }, createSeededRandom('b')); + expect(a).not.toEqual(b); + }); + + it('yields values in [0, 1)', () => { + const rng = createSeededRandom('range-check'); + for (let i = 0; i < 1000; i++) { + const v = rng(); + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThan(1); + } + }); + + it('mints seed strings from the CSPRNG with the expected shape', () => { + const seed = randomSeedString(); + expect(seed).toMatch(/^[a-z2-9]{8}$/); + expect(randomSeedString(16)).toHaveLength(16); + // Two mints are (overwhelmingly) distinct + expect(randomSeedString()).not.toBe(randomSeedString()); + }); +}); + +describe('deterministic generation', () => { + it.each(templateIds)('%s: same seed → identical instance', (id) => { + const first = templateEngine.generate(id, 'seed-1'); + const second = templateEngine.generate(id, 'seed-1'); + expect(second.statement).toEqual(first.statement); + expect(second.parameters).toEqual(first.parameters); + expect(second.hints).toEqual(first.hints); + expect(second.solution).toEqual(first.solution); + expect(second.graded).toEqual(first.graded); + expect(second.seed).toBe('seed-1'); + }); + + it.each(templateIds)('%s: different seeds vary the parameters', (id) => { + const base = templateEngine.generate(id, 'vary-0'); + const anyDiffers = ['vary-1', 'vary-2', 'vary-3', 'vary-4', 'vary-5'].some( + (seed) => + JSON.stringify(templateEngine.generate(id, seed).parameters) !== + JSON.stringify(base.parameters), + ); + expect(anyDiffers).toBe(true); + }); + + it('round-trips the seed stored on the instance', () => { + const instance = templateEngine.generate('quadratic-equation-basic', 'url-seed'); + const reproduced = templateEngine.generate('quadratic-equation-basic', instance.seed); + expect(reproduced.statement).toBe(instance.statement); + expect(reproduced.parameters).toEqual(instance.parameters); + }); + + it('accepts numeric seeds by stringifying them', () => { + const a = templateEngine.generate('linear-equation-basic', 42); + const b = templateEngine.generate('linear-equation-basic', '42'); + expect(a.parameters).toEqual(b.parameters); + expect(a.seed).toBe('42'); + }); + + it('generateBatch derives reproducible per-item seeds', () => { + const batchA = templateEngine.generateBatch('linear-equation-basic', 5, 'batch'); + const batchB = templateEngine.generateBatch('linear-equation-basic', 5, 'batch'); + expect(batchA.map((p) => p.parameters)).toEqual(batchB.map((p) => p.parameters)); + expect(batchA[0]!.seed).toBe('batch:0'); + + // Default baseSeed is the template id + const defaulted = templateEngine.generateBatch('linear-equation-basic', 2); + expect(defaulted[1]!.seed).toBe('linear-equation-basic:1'); + }); +}); + +describe('constraint satisfaction (100-seed sweep)', () => { + it.each(SWEEP_SEEDS)('quadratic discriminant ≥ 0 (%s)', (seed) => { + const { parameters } = templateEngine.generate('quadratic-equation-basic', seed); + const { a, b, c } = narrow<{ a: number; b: number; c: number }>(parameters); + expect(b * b - 4 * a * c).toBeGreaterThanOrEqual(0); + }); + + it.each(SWEEP_SEEDS)('2x2 system determinant ≠ 0 (%s)', (seed) => { + const { parameters } = templateEngine.generate('system-linear-2x2', seed); + const { a1, b1, a2, b2 } = narrow<{ a1: number; b1: number; a2: number; b2: number }>( + parameters, + ); + expect(a1 * b2 - a2 * b1).not.toBe(0); + }); + + it.each(SWEEP_SEEDS)('factorization has an integer factor pair (%s)', (seed) => { + const { parameters } = templateEngine.generate('polynomial-factorization', seed); + const { b, c } = narrow<{ b: number; c: number }>(parameters); + let found = false; + for (let i = -20; i <= 20 && !found; i++) { + if (i !== 0 && c % i === 0 && i + c / i === b) found = true; + } + expect(found).toBe(true); + }); + + it.each(SWEEP_SEEDS)('combinations/permutations keep r ≤ n (%s)', (seed) => { + for (const id of ['combinations-basic', 'permutations-basic']) { + const { parameters } = templateEngine.generate(id, seed); + const { n, r } = narrow<{ n: number; r: number }>(parameters); + expect(r).toBeLessThanOrEqual(n); + } + }); + + const statementSeeds = SWEEP_SEEDS.slice(0, 10); + it.each(templateIds)('%s: no unsubstituted placeholders remain', (id) => { + for (const seed of statementSeeds) { + const { statement } = templateEngine.generate(id, seed); + expect(statement).not.toContain('{{'); + } + }); +}); + +describe('graded answers stay exact (solver back-substitution)', () => { + /** Original-equation builders: LHS/RHS as parseable strings per template. */ + const originalEquations: Record< + string, + (params: Record) => { lhs: string; rhs: string } + > = { + 'linear-equation-basic': ({ a, b, c }) => ({ lhs: `${a}*x + ${b}`, rhs: `${c}` }), + 'quadratic-equation-basic': ({ a, b, c }) => ({ + lhs: `${a}*x^2 + ${b}*x + ${c}`, + rhs: '0', + }), + 'absolute-value-equation': ({ a, b }) => ({ lhs: `abs(x + ${a})`, rhs: `${b}` }), + 'radical-equation-square': ({ a, b }) => ({ lhs: `sqrt(x + ${a})`, rhs: `${b}` }), + }; + + const solutionTemplateIds = Object.keys(originalEquations); + const exactnessSeeds = SWEEP_SEEDS.slice(0, 25); + + it.each(solutionTemplateIds)('%s: every graded root satisfies the ORIGINAL equation', (id) => { + for (const seed of exactnessSeeds) { + const instance = templateEngine.generate(id, seed); + expect(instance.graded, `graded missing for ${id} @ ${seed}`).toBeDefined(); + const graded = instance.graded!; + expect(graded.kind).toBe('solutions'); + if (graded.kind !== 'solutions') continue; + expect(graded.values.length).toBeGreaterThan(0); + + const numericParams: Record = {}; + for (const [k, v] of Object.entries(instance.parameters)) { + if (typeof v === 'number') numericParams[k] = v; + } + const { lhs, rhs } = originalEquations[id]!(numericParams); + + for (const value of graded.values) { + const left = evaluate(lhs, { variables: { x: value }, mode: 'approximate' }); + const right = evaluate(rhs, { variables: { x: value }, mode: 'approximate' }); + expect(left.success && right.success).toBe(true); + if (left.success && right.success) { + expect(Math.abs(Number(left.value) - Number(right.value))).toBeLessThan(1e-6); + } + } + } + }); + + it('system-linear-2x2: graded assignments satisfy both equations', () => { + for (const seed of exactnessSeeds) { + const instance = templateEngine.generate('system-linear-2x2', seed); + const graded = instance.graded; + expect(graded?.kind).toBe('assignments'); + if (graded?.kind !== 'assignments') continue; + const { a1, b1, c1, a2, b2, c2 } = narrow<{ + a1: number; + b1: number; + c1: number; + a2: number; + b2: number; + c2: number; + }>(instance.parameters); + const [x, y] = graded.values; + expect(graded.variables).toEqual(['x', 'y']); + expect(Math.abs(a1 * x! + b1 * y! - c1)).toBeLessThan(1e-9); + expect(Math.abs(a2 * x! + b2 * y! - c2)).toBeLessThan(1e-9); + } + }); + + it('number-kind graded values match the template arithmetic', () => { + for (const seed of exactnessSeeds) { + const limit = templateEngine.generate('limit-basic', seed); + const lp = narrow<{ a: number; b: number; c: number }>(limit.parameters); + expect(limit.graded).toEqual({ kind: 'number', value: lp.b * lp.a + lp.c }); + + const probability = templateEngine.generate('probability-basic', seed); + const pp = narrow<{ red: number; blue: number }>(probability.parameters); + expect(probability.graded).toEqual({ + kind: 'number', + value: pp.red / (pp.red + pp.blue), + }); + + const modular = templateEngine.generate('modular-arithmetic-basic', seed); + const mp = narrow<{ a: number; m: number }>(modular.parameters); + expect(modular.graded).toEqual({ kind: 'number', value: mp.a % mp.m }); + } + }); + + it('expression-kind canonical strings are parseable', () => { + const expressionTemplates = allTemplates.filter((t) => t.canonical); + for (const template of expressionTemplates) { + for (const seed of exactnessSeeds.slice(0, 5)) { + const instance = templateEngine.generate(template.id, seed); + if (instance.graded?.kind !== 'expression') continue; + const result = evaluate(instance.graded.expression, { + variables: { x: 1.2345 }, + mode: 'approximate', + }); + expect(result.success, `${template.id} canonical failed to evaluate`).toBe(true); + } + } + }); + + it('templates without machine-gradable answers omit graded (never guess)', () => { + for (const id of ['prime-factorization', 'linear-inequality']) { + const instance = templateEngine.generate(id, 'no-graded'); + expect(instance.graded).toBeUndefined(); + } + }); +}); diff --git a/packages/math-engine/src/problems/templates/template-engine.ts b/packages/math-engine/src/problems/templates/template-engine.ts index 4156d880..88a6d47b 100644 --- a/packages/math-engine/src/problems/templates/template-engine.ts +++ b/packages/math-engine/src/problems/templates/template-engine.ts @@ -3,12 +3,16 @@ * * Provides infrastructure for creating mathematical problems from templates: * - Template parsing and variable substitution + * - Deterministic seed-based parameterization (same seed → same problem) * - Difficulty scaling - * - Solution generation + * - Solution generation with CAS-computed exact graded answers * - Hint system * - Common mistake detection */ +import { Complex, solve } from '../../solver'; +import { createSeededRandom } from './seeded-rng'; + /** * Possible parameter values generated by the template engine */ @@ -53,6 +57,39 @@ export interface ParameterSpec { description?: string; } +/** + * Canonical answer specification, declared per template and computed + * from the generated parameters. The engine turns this into a + * {@link GradedAnswer} at generate() time. + */ +export type CanonicalSpec = + | { kind: 'solutions'; equation: string; variable: string } + | { kind: 'expression'; expression: string; variable?: string } + | { kind: 'number'; value: number; tolerance?: number } + | { kind: 'assignments'; variables: ReadonlyArray; values: ReadonlyArray }; + +/** + * Machine-gradable answer attached to a generated problem instance. + * + * - `solutions` — the exact real roots of `equation` (computed via the + * closed-form solver), order-insensitive multiset for grading. + * - `expression` — a canonical expression; student input is graded via + * CAS equivalence checking. + * - `number` — a single numeric value (optionally with a custom + * tolerance for π-style answers). + * - `assignments` — labeled variable values (e.g. systems: x = 2, y = 3). + */ +export type GradedAnswer = + | { + kind: 'solutions'; + equation: string; + variable: string; + values: ReadonlyArray; + } + | { kind: 'expression'; expression: string; variable?: string } + | { kind: 'number'; value: number; tolerance?: number } + | { kind: 'assignments'; variables: ReadonlyArray; values: ReadonlyArray }; + /** * Generated problem instance */ @@ -69,6 +106,10 @@ export interface ProblemInstance { difficulty: 1 | 2 | 3 | 4 | 5; /** Common mistakes to avoid */ commonMistakes: Mistake[]; + /** Seed used to generate this instance (round-trips through URLs) */ + seed: string; + /** Machine-gradable canonical answer (when the template declares one) */ + graded?: GradedAnswer; } /** @@ -150,6 +191,11 @@ export interface ProblemTemplate { prerequisites?: string[]; /** Learning objectives */ learningObjectives?: string[]; + /** + * Canonical machine-gradable answer for the generated parameters. + * When present, generate() computes {@link ProblemInstance.graded}. + */ + canonical?: (params: TemplateParams) => CanonicalSpec; } /** @@ -157,7 +203,6 @@ export interface ProblemTemplate { */ export class TemplateEngine { private templates = new Map(); - private random = Math.random; /** * Register a template @@ -176,21 +221,24 @@ export class TemplateEngine { } /** - * Generate a problem from a template + * Generate a problem from a template. + * + * Generation is pure and deterministic: the seed creates a local RNG + * stream, so the same (templateId, seed) pair always produces the + * identical problem — safe under concurrent generation and React + * StrictMode double-invocation. */ - generate(templateId: string, seed?: number): ProblemInstance { + generate(templateId: string, seed: string | number): ProblemInstance { const template = this.templates.get(templateId); if (!template) { throw new Error(`Template ${templateId} not found`); } - // Use seed for reproducibility - if (seed !== undefined) { - this.random = this.seededRandom(seed); - } + const seedString = String(seed); + const rng = createSeededRandom(seedString); // Generate parameter values - const parameters = this.generateParameters(template.parameters); + const parameters = this.generateParameters(template.parameters, rng); // Substitute template const statement = this.substituteTemplate(template.template, parameters); @@ -206,6 +254,11 @@ export class TemplateEngine { .map((detector) => detector(parameters, '')) .filter((m): m is Mistake => m !== null); + // Compute the machine-gradable answer from the canonical spec + const graded = template.canonical + ? this.computeGraded(template.canonical(parameters)) + : undefined; + return { statement, solution, @@ -213,22 +266,82 @@ export class TemplateEngine { parameters, difficulty: template.difficulty, commonMistakes, + seed: seedString, + ...(graded ? { graded } : {}), }; } /** - * Generate multiple problems from a template + * Generate multiple problems from a template. + * Seeds are derived as `${baseSeed}:${i}`, so batches are reproducible. */ - generateBatch(templateId: string, count: number): ProblemInstance[] { + generateBatch(templateId: string, count: number, baseSeed?: string): ProblemInstance[] { const problems: ProblemInstance[] = []; + const base = baseSeed ?? templateId; for (let i = 0; i < count; i++) { - problems.push(this.generate(templateId, i)); + problems.push(this.generate(templateId, `${base}:${i}`)); } return problems; } + /** + * Turn a canonical spec into a graded answer. + * For `solutions`, roots are computed via the exact closed-form solver; + * if no real closed-form solution can be supported, no graded answer is + * attached (grading falls back to the display answer, never guesses). + */ + private computeGraded(spec: CanonicalSpec): GradedAnswer | undefined { + switch (spec.kind) { + case 'solutions': { + try { + const solutions = solve(spec.equation, spec.variable); + const values: number[] = []; + for (const sol of solutions) { + if (typeof sol.value === 'number') { + if (!Number.isFinite(sol.value)) return undefined; + values.push(sol.value); + } else if (sol.value instanceof Complex) { + if (Math.abs(sol.value.imag) > 1e-9) return undefined; + values.push(sol.value.real); + } + } + if (values.length === 0) return undefined; + return { + kind: 'solutions', + equation: spec.equation, + variable: spec.variable, + values, + }; + } catch { + return undefined; + } + } + case 'expression': + return { + kind: 'expression', + expression: spec.expression, + ...(spec.variable !== undefined ? { variable: spec.variable } : {}), + }; + case 'number': + if (!Number.isFinite(spec.value)) return undefined; + return { + kind: 'number', + value: spec.value, + ...(spec.tolerance !== undefined ? { tolerance: spec.tolerance } : {}), + }; + case 'assignments': + if (spec.variables.length !== spec.values.length || spec.variables.length === 0) { + return undefined; + } + if (spec.values.some((v) => !Number.isFinite(v))) return undefined; + return { kind: 'assignments', variables: spec.variables, values: spec.values }; + default: + return undefined; + } + } + /** * Find templates by criteria */ @@ -267,7 +380,7 @@ export class TemplateEngine { /** * Generate parameters for a template */ - private generateParameters(specs: ParameterSpec[]): TemplateParams { + private generateParameters(specs: ParameterSpec[], rng: () => number): TemplateParams { const params: TemplateParams = {}; for (const spec of specs) { @@ -284,7 +397,7 @@ export class TemplateEngine { } do { - value = this.generateParameter(spec); + value = this.generateParameter(spec, rng); attempts++; } while ( spec.constraint && @@ -305,25 +418,25 @@ export class TemplateEngine { /** * Generate a single parameter value */ - private generateParameter(spec: ParameterSpec): ParameterValue { + private generateParameter(spec: ParameterSpec, rng: () => number): ParameterValue { switch (spec.type) { case 'integer': { const min = spec.min ?? 0; const max = spec.max ?? 100; - return Math.floor(this.random() * (max - min + 1)) + min; + return Math.floor(rng() * (max - min + 1)) + min; } case 'real': { const min = spec.min ?? 0; const max = spec.max ?? 1; - return this.random() * (max - min) + min; + return rng() * (max - min) + min; } case 'rational': { - const numerator = this.generateParameter({ ...spec, type: 'integer' }) as number; + const numerator = this.generateParameter({ ...spec, type: 'integer' }, rng) as number; let denominator: number; do { - denominator = this.generateParameter({ ...spec, type: 'integer', min: 1 }) as number; + denominator = this.generateParameter({ ...spec, type: 'integer', min: 1 }, rng) as number; } while (denominator === 0); return { numerator, denominator }; } @@ -332,13 +445,13 @@ export class TemplateEngine { if (!spec.choices || spec.choices.length === 0) { throw new Error(`No choices provided for parameter ${spec.name}`); } - const index = Math.floor(this.random() * spec.choices.length); + const index = Math.floor(rng() * spec.choices.length); return spec.choices[index]!; } case 'expression': { // Generate simple expressions - return this.generateSimpleExpression(); + return this.generateSimpleExpression(rng); } default: @@ -379,27 +492,15 @@ export class TemplateEngine { /** * Generate a simple algebraic expression */ - private generateSimpleExpression(): string { + private generateSimpleExpression(rng: () => number): string { const operators = ['+', '-', '*']; - const operator = operators[Math.floor(this.random() * operators.length)]; - const a = Math.floor(this.random() * 10) + 1; - const b = Math.floor(this.random() * 10) + 1; + const operator = operators[Math.floor(rng() * operators.length)]; + const a = Math.floor(rng() * 10) + 1; + const b = Math.floor(rng() * 10) + 1; return `${a} ${operator} ${b}`; } - /** - * Seeded random number generator - */ - private seededRandom(seed: number): () => number { - let state = seed; - - return () => { - state = (state * 1664525 + 1013904223) % 4294967296; - return state / 4294967296; - }; - } - /** * Get all registered templates */ From 66993aa015161aa8d98a79720a6c5409b0001a8b Mon Sep 17 00:00:00 2001 From: ABCrimson <231791317+ABCrimson@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:35:54 -0500 Subject: [PATCH 2/5] =?UTF-8?q?feat(math-engine):=20equivalence=20module?= =?UTF-8?q?=20=E2=80=94=20symbolic=20+=20seeded=20numeric=20probing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit checkEquivalence(student, canonical): parse both, prove simplify(student - canonical) == 0 via symbolic/simplify + CAS; fall back to deterministic seeded numeric probing (24 points in [-5,5] plus positive-domain points for sqrt/log; singularities skipped; >=8 valid samples required; mixed abs/rel tolerance 1e-9). Conservative by construction: any mismatch rejects, insufficient samples reports 'inconclusive' — it never claims equivalence it cannot support. checkGradedAnswer grades free-form input against GradedAnswer kinds: multi-solution lists ('x = 2 or x = 3', '2; 3'), numeric values (expression input like 1/2 or 16*pi accepted), labeled assignments ('x = 2, y = 3' any order, swapped values rejected), and canonical expressions with '+ C' and assignment-prefix stripping. Co-Authored-By: Claude Fable 5 --- .../src/equivalence/equivalence.test.ts | 222 ++++++++++ .../src/equivalence/equivalence.ts | 382 ++++++++++++++++++ packages/math-engine/src/equivalence/index.ts | 6 + 3 files changed, 610 insertions(+) create mode 100644 packages/math-engine/src/equivalence/equivalence.test.ts create mode 100644 packages/math-engine/src/equivalence/equivalence.ts create mode 100644 packages/math-engine/src/equivalence/index.ts diff --git a/packages/math-engine/src/equivalence/equivalence.test.ts b/packages/math-engine/src/equivalence/equivalence.test.ts new file mode 100644 index 00000000..3bfa888e --- /dev/null +++ b/packages/math-engine/src/equivalence/equivalence.test.ts @@ -0,0 +1,222 @@ +/** + * Equivalence module tests: symbolic hits, numeric-probing fallback, + * negatives, parse errors, determinism, tolerance edges, and + * graded-answer checking. + */ + +import { describe, expect, it } from 'vitest'; +import { checkEquivalence, checkGradedAnswer, normalizeAnswerExpression } from './equivalence'; + +describe('checkEquivalence — equivalent forms', () => { + it.each([ + ['(x+1)^2', 'x^2 + 2*x + 1'], + ['sin(x)^2 + cos(x)^2', '1'], + ['2*x + 3 - x', 'x + 3'], + ['(x - 2)*(x - 3)', 'x^2 - 5*x + 6'], + ['2/4', '1/2'], + ['x/2 + x/2', 'x'], + ['exp(x) * exp(x)', 'exp(2*x)'], + ])('%s ≡ %s', (student, canonical) => { + const result = checkEquivalence(student, canonical); + expect(result.equivalent).toBe(true); + expect(result.confidence).toBeGreaterThan(0); + }); + + it('proves trivially-cancelling differences symbolically', () => { + const result = checkEquivalence('x - x', '0'); + expect(result.equivalent).toBe(true); + expect(result.method).toBe('symbolic'); + expect(result.reason).toBe('simplified-to-zero'); + expect(result.confidence).toBe(1); + }); + + it('falls back to numeric probing across a removable singularity', () => { + // (x^2-1)/(x-1) ≡ x+1 everywhere except x = 1 — the singular probe + // point is skipped, and ≥ 8 valid samples must remain. + const result = checkEquivalence('(x^2 - 1)/(x - 1)', 'x + 1'); + expect(result.equivalent).toBe(true); + expect(result.confidence).toBeGreaterThan(0.3); + }); + + it('handles sqrt/log domain restrictions via positive-domain samples', () => { + const result = checkEquivalence('sqrt(x)*sqrt(x)', 'x'); + expect(result.equivalent).toBe(true); + }); +}); + +describe('checkEquivalence — non-equivalent and edge cases', () => { + it.each([ + ['x^2', 'x^3'], + ['x + 1', 'x - 1'], + ['2*x', 'x^2'], + ['(x+1)^2', 'x^2 + 1'], + ['3/4', '2/3'], + ])('%s ≢ %s', (student, canonical) => { + const result = checkEquivalence(student, canonical); + expect(result.equivalent).toBe(false); + expect(result.reason).toBe('counterexample'); + }); + + it('reports parse errors without guessing', () => { + const result = checkEquivalence('(x + ', 'x'); + expect(result.equivalent).toBe(false); + expect(result.method).toBe('symbolic'); + expect(result.reason).toBe('parse-error'); + expect(result.confidence).toBe(1); + }); + + it('never claims equivalence with too few valid samples (inconclusive)', () => { + // Both sides are undefined for every real probe point, so no sample + // is valid — the checker must refuse rather than claim equivalence. + const result = checkEquivalence('sqrt(-25 - x^2) + 1', 'sqrt(-25 - x^2) + 2'); + expect(result.equivalent).toBe(false); + expect(result.reason).toBe('inconclusive'); + }); + + it('is deterministic: identical calls produce identical result objects', () => { + const a = checkEquivalence('(x^2 - 1)/(x - 1)', 'x + 1'); + const b = checkEquivalence('(x^2 - 1)/(x - 1)', 'x + 1'); + expect(b).toEqual(a); + + const negA = checkEquivalence('sin(x)', 'cos(x)'); + const negB = checkEquivalence('sin(x)', 'cos(x)'); + expect(negB).toEqual(negA); + }); + + it('respects the seed option deterministically', () => { + const a = checkEquivalence('x^2 + x', 'x*(x + 1)', { seed: 'probe-seed' }); + const b = checkEquivalence('x^2 + x', 'x*(x + 1)', { seed: 'probe-seed' }); + expect(b).toEqual(a); + expect(a.equivalent).toBe(true); + }); + + it('tolerance edges: rejects offsets above tolerance, absorbs float dust', () => { + // 1e-6 offset is far above the default 1e-9 mixed tolerance + expect(checkEquivalence('x + 0.000001', 'x').equivalent).toBe(false); + // 1e-15 is float dust — within mixed tolerance by design + expect(checkEquivalence('x + 1e-15', 'x').equivalent).toBe(true); + // Custom tolerance widens acceptance explicitly + expect(checkEquivalence('x + 0.000001', 'x', { tolerance: 1e-4 }).equivalent).toBe(true); + }); + + it('compares constant expressions with a single evaluation', () => { + const result = checkEquivalence('2 + 3', '5'); + expect(result.equivalent).toBe(true); + expect(result.confidence).toBe(1); + + expect(checkEquivalence('2 + 3', '6').equivalent).toBe(false); + }); +}); + +describe('checkGradedAnswer — solutions kind', () => { + const graded = { + kind: 'solutions', + equation: 'x^2 - 5*x + 6 = 0', + variable: 'x', + values: [2, 3], + } as const; + + it.each([ + ['x = 3 or x = 2'], + ['x = 2 or x = 3'], + ['x=2, x=-3'.replace('-3', '3')], + ['2, 3'], + ['3; 2'], + ['2 and 3'], + ['x = 6/3, x = 3'], + ])('accepts "%s"', (answer) => { + expect(checkGradedAnswer(answer, graded).correct).toBe(true); + }); + + it.each([['2'], ['x = 2'], ['2, 4'], ['2, 3, 4'], ['banana'], ['']])('rejects "%s"', (answer) => { + expect(checkGradedAnswer(answer, graded).correct).toBe(false); + }); + + it('treats a double root written once or twice as correct', () => { + const doubleRoot = { + kind: 'solutions', + equation: 'x^2 - 6*x + 9 = 0', + variable: 'x', + values: [3], + } as const; + expect(checkGradedAnswer('x = 3', doubleRoot).correct).toBe(true); + expect(checkGradedAnswer('3, 3', doubleRoot).correct).toBe(true); + expect(checkGradedAnswer('3, 4', doubleRoot).correct).toBe(false); + }); +}); + +describe('checkGradedAnswer — number kind', () => { + it('accepts equivalent numeric expressions', () => { + const graded = { kind: 'number', value: 0.5 } as const; + expect(checkGradedAnswer('1/2', graded).correct).toBe(true); + expect(checkGradedAnswer('0.5', graded).correct).toBe(true); + expect(checkGradedAnswer('x = 0.5', graded).correct).toBe(true); + expect(checkGradedAnswer('0.51', graded).correct).toBe(false); + expect(checkGradedAnswer('not-a-number', graded).correct).toBe(false); + }); + + it('applies the per-answer tolerance for π-style values', () => { + const graded = { kind: 'number', value: Math.PI * 16, tolerance: 1e-3 } as const; + expect(checkGradedAnswer('16*pi', graded).correct).toBe(true); + expect(checkGradedAnswer('50.27', graded).correct).toBe(true); // 2-dp rounding + expect(checkGradedAnswer('50.9', graded).correct).toBe(false); + }); +}); + +describe('checkGradedAnswer — assignments kind', () => { + const graded = { + kind: 'assignments', + variables: ['x', 'y'], + values: [2, 3], + } as const; + + it('accepts labeled assignments in any order', () => { + expect(checkGradedAnswer('x = 2, y = 3', graded).correct).toBe(true); + expect(checkGradedAnswer('y = 3, x = 2', graded).correct).toBe(true); + expect(checkGradedAnswer('x=2; y=3', graded).correct).toBe(true); + }); + + it('rejects swapped labeled values (never grades by luck)', () => { + expect(checkGradedAnswer('x = 3, y = 2', graded).correct).toBe(false); + }); + + it('interprets unlabeled input in declared variable order', () => { + expect(checkGradedAnswer('2, 3', graded).correct).toBe(true); + expect(checkGradedAnswer('3, 2', graded).correct).toBe(false); + }); + + it('rejects wrong arity', () => { + expect(checkGradedAnswer('2', graded).correct).toBe(false); + expect(checkGradedAnswer('2, 3, 4', graded).correct).toBe(false); + }); +}); + +describe('checkGradedAnswer — expression kind', () => { + const graded = { kind: 'expression', expression: '(3/4)*x^4', variable: 'x' } as const; + + it('accepts equivalent forms and strips a trailing + C', () => { + expect(checkGradedAnswer('3*x^4/4 + C', graded).correct).toBe(true); + expect(checkGradedAnswer('0.75*x^4', graded).correct).toBe(true); + expect(checkGradedAnswer('F(x) = 3*x^4/4 + C', graded).correct).toBe(true); + }); + + it('rejects non-equivalent expressions', () => { + expect(checkGradedAnswer('3*x^3/4', graded).correct).toBe(false); + expect(checkGradedAnswer('x^4', graded).correct).toBe(false); + expect(checkGradedAnswer('', graded).correct).toBe(false); + }); + + it('accepts factored vs expanded polynomial forms', () => { + const poly = { kind: 'expression', expression: 'x^2 + 5*x + 6', variable: 'x' } as const; + expect(checkGradedAnswer('(x + 2)*(x + 3)', poly).correct).toBe(true); + expect(checkGradedAnswer('(x + 1)*(x + 6)', poly).correct).toBe(false); + }); +}); + +describe('normalizeAnswerExpression', () => { + it('strips assignment prefixes and + C suffixes', () => { + expect(normalizeAnswerExpression("f'(x) = 6*x + C")).toBe('6*x'); + expect(normalizeAnswerExpression('y = x^2')).toBe('x^2'); + expect(normalizeAnswerExpression(' x + 1 ')).toBe('x + 1'); + }); +}); diff --git a/packages/math-engine/src/equivalence/equivalence.ts b/packages/math-engine/src/equivalence/equivalence.ts new file mode 100644 index 00000000..ee60685b --- /dev/null +++ b/packages/math-engine/src/equivalence/equivalence.ts @@ -0,0 +1,382 @@ +/** + * Expression equivalence checking. + * + * `checkEquivalence(student, canonical)` decides whether two expression + * strings are mathematically equivalent: + * + * 1. **Symbolic**: simplify(student − canonical) via the rule-based + * simplifier and the CAS — a literal constant `0` proves equivalence. + * 2. **Numeric probing fallback**: deterministic seeded sampling of the + * shared variables; every valid sample must agree within a mixed + * absolute/relative tolerance. A single mismatch rejects; too few + * valid samples yields `inconclusive` (never a false "equivalent"). + * + * `checkGradedAnswer(answer, graded)` grades free-form student input + * against a {@link GradedAnswer} produced by the template engine: + * multi-solution lists ("x = 2 or x = 3", "2; 3"), numeric values + * (accepting expressions like `1/2` or `16*pi`), labeled assignments + * ("x = 2, y = 3"), and canonical expressions (with `+ C` stripping). + * + * Determinism: no `Math.random` anywhere — probing uses the seeded RNG, + * keyed by the input pair by default, so identical inputs always probe + * identical points. + */ + +import { ComputerAlgebraSystem } from '../cas'; +import { evaluate, extractVariables, parse } from '../parser'; +import { createOperatorNode, type ExpressionNode, isConstantNode } from '../parser/ast'; +import { createSeededRandom } from '../problems/templates/seeded-rng'; +import type { GradedAnswer } from '../problems/templates/template-engine'; +import { simplify } from '../symbolic/simplify'; + +export type { GradedAnswer }; + +/** Options for {@link checkEquivalence}. */ +export interface EquivalenceOptions { + /** Probe-point seed. Defaults to `${student}|${canonical}` (deterministic). */ + seed?: string; + /** Number of probe points (default 24). */ + samples?: number; + /** Sampling domain for probe points (default [-5, 5]). */ + domain?: readonly [number, number]; + /** Mixed abs/rel tolerance: |a−b| ≤ tol·max(1,|a|,|b|) (default 1e-9). */ + tolerance?: number; +} + +/** Why an equivalence verdict was reached. */ +export type EquivalenceReason = + | 'parse-error' + | 'simplified-to-zero' + | 'numeric-agreement' + | 'counterexample' + | 'inconclusive'; + +/** Result of {@link checkEquivalence}. */ +export interface EquivalenceResult { + equivalent: boolean; + method: 'symbolic' | 'numeric'; + /** Fraction of probe points that evaluated cleanly (1 for symbolic). */ + confidence: number; + reason?: EquivalenceReason; +} + +/** Result of {@link checkGradedAnswer}. */ +export interface GradedCheckResult { + correct: boolean; + method: 'symbolic' | 'numeric'; +} + +const MIN_VALID_SAMPLES = 8; +const DEFAULT_SAMPLES = 24; +const DEFAULT_TOLERANCE = 1e-9; +const SOLUTION_TOLERANCE = 1e-6; +const SINGULARITY_CUTOFF = 1e12; + +/** Mixed absolute/relative comparison: |a−b| ≤ tol·max(1, |a|, |b|). */ +function approxEqual(a: number, b: number, tolerance: number): boolean { + return Math.abs(a - b) <= tolerance * Math.max(1, Math.abs(a), Math.abs(b)); +} + +/** True when a node is the literal constant 0 (number or bigint). */ +function isZeroConstant(node: ExpressionNode): boolean { + if (!isConstantNode(node)) return false; + if (typeof node.value === 'number') return node.value === 0; + if (typeof node.value === 'bigint') return node.value === 0n; + return false; +} + +/** + * Evaluate an AST to a finite number, or null when evaluation fails, + * produces a non-finite value, or exceeds the singularity cutoff. + */ +function evalNumber(ast: ExpressionNode, variables: Record): number | null { + const result = evaluate(ast, { variables, mode: 'approximate' }); + if (!result.success) return null; + const value = typeof result.value === 'bigint' ? Number(result.value) : result.value; + if (typeof value !== 'number') return null; + if (!Number.isFinite(value) || Math.abs(value) > SINGULARITY_CUTOFF) return null; + return value; +} + +/** Evaluate a constant (variable-free) expression string to a number. */ +function evalConstant(expression: string): number | null { + try { + const ast = parse(expression); + if (extractVariables(ast).size > 0) return null; + return evalNumber(ast, {}); + } catch { + return null; + } +} + +/** + * Strip a leading assignment prefix such as `x =`, `y =`, `f(x) =`, + * or `f'(x) =` from a student answer. + */ +function stripLeadingAssignment(input: string): string { + return input.replace( + /^\s*[A-Za-z][A-Za-z0-9_]*'*\s*(?:\(\s*[A-Za-z][A-Za-z0-9_]*\s*\))?\s*=\s*/, + '', + ); +} + +/** Strip a trailing constant of integration (`+ C` / `+c`). */ +function stripConstantOfIntegration(input: string): string { + return input.replace(/\s*\+\s*[Cc]\s*$/, ''); +} + +/** + * Check whether two expression strings are mathematically equivalent. + * + * The check is conservative: it only returns `equivalent: true` when it + * can support the claim symbolically (difference simplifies to the + * literal constant 0) or numerically (≥ {@link MIN_VALID_SAMPLES} clean + * probe points, all within tolerance, zero mismatches). + */ +export function checkEquivalence( + student: string, + canonical: string, + options: EquivalenceOptions = {}, +): EquivalenceResult { + let studentAst: ExpressionNode; + let canonicalAst: ExpressionNode; + try { + studentAst = parse(student); + canonicalAst = parse(canonical); + } catch { + return { equivalent: false, method: 'symbolic', confidence: 1, reason: 'parse-error' }; + } + + // ── 1. Symbolic: simplify(student − canonical) must be literally 0 ── + try { + const diff = createOperatorNode('-', 'subtract', [studentAst, canonicalAst]); + const simplified = simplify(diff); + if (isZeroConstant(simplified)) { + return { equivalent: true, method: 'symbolic', confidence: 1, reason: 'simplified-to-zero' }; + } + const casSimplified = new ComputerAlgebraSystem().simplify(simplified).expression; + if (isZeroConstant(casSimplified)) { + return { equivalent: true, method: 'symbolic', confidence: 1, reason: 'simplified-to-zero' }; + } + } catch { + // Symbolic machinery failed on this shape — fall through to probing. + } + + // ── 2. Numeric probing fallback (deterministic, seeded) ── + const tolerance = options.tolerance ?? DEFAULT_TOLERANCE; + const variableNames = [ + ...new Set([...extractVariables(studentAst), ...extractVariables(canonicalAst)]), + ]; + + if (variableNames.length === 0) { + // Constant expressions: a single evaluation decides. + const a = evalNumber(studentAst, {}); + const b = evalNumber(canonicalAst, {}); + if (a === null || b === null) { + return { equivalent: false, method: 'numeric', confidence: 0, reason: 'inconclusive' }; + } + const equal = approxEqual(a, b, tolerance); + return { + equivalent: equal, + method: 'numeric', + confidence: 1, + reason: equal ? 'numeric-agreement' : 'counterexample', + }; + } + + const samples = options.samples ?? DEFAULT_SAMPLES; + const [lo, hi] = options.domain ?? ([-5, 5] as const); + const rng = createSeededRandom(options.seed ?? `${student}|${canonical}`); + // Reserve a few trailing samples for the positive domain (0.1, 3] so + // sqrt/log expressions still collect enough valid points. + const positiveSamples = Math.min(6, Math.max(1, Math.floor(samples / 4))); + + let valid = 0; + for (let i = 0; i < samples; i++) { + const usePositiveDomain = i >= samples - positiveSamples; + const variables: Record = {}; + for (const name of variableNames) { + variables[name] = usePositiveDomain ? 0.1 + rng() * 2.9 : lo + rng() * (hi - lo); + } + + const a = evalNumber(studentAst, variables); + const b = evalNumber(canonicalAst, variables); + if (a === null || b === null) continue; // singularity or domain error — skip point + + valid++; + if (!approxEqual(a, b, tolerance)) { + return { + equivalent: false, + method: 'numeric', + confidence: valid / samples, + reason: 'counterexample', + }; + } + } + + if (valid < MIN_VALID_SAMPLES) { + return { + equivalent: false, + method: 'numeric', + confidence: valid / samples, + reason: 'inconclusive', + }; + } + + return { + equivalent: true, + method: 'numeric', + confidence: valid / samples, + reason: 'numeric-agreement', + }; +} + +/** + * Parse a student's multi-solution answer into numeric values. + * Accepts "x = 2 or x = 3", "x=2, x=-3", "2; 3", "2, 3", "1/2", + * "sqrt(2)" — each fragment must evaluate to a variable-free number. + * Returns null when any fragment fails to evaluate. + */ +function parseSolutionList(input: string): number[] | null { + const fragments = input + .split(/\bor\b|\band\b|[,;]/i) + .map((fragment) => stripLeadingAssignment(fragment.trim())) + .filter((fragment) => fragment.length > 0); + + if (fragments.length === 0) return null; + + const values: number[] = []; + for (const fragment of fragments) { + const value = evalConstant(fragment); + if (value === null) return null; + values.push(value); + } + return values; +} + +/** Deduplicate numeric values within tolerance (order-preserving). */ +function dedupe(values: ReadonlyArray, tolerance: number): number[] { + const out: number[] = []; + for (const value of values) { + if (!out.some((existing) => approxEqual(existing, value, tolerance))) { + out.push(value); + } + } + return out; +} + +/** Order-insensitive multiset match within tolerance. */ +function multisetMatch( + a: ReadonlyArray, + b: ReadonlyArray, + tolerance: number, +): boolean { + if (a.length !== b.length) return false; + const remaining = [...b]; + for (const value of a) { + const index = remaining.findIndex((candidate) => approxEqual(candidate, value, tolerance)); + if (index === -1) return false; + remaining.splice(index, 1); + } + return true; +} + +/** + * Grade a free-form student answer against a machine-gradable answer. + * + * - `solutions`: order-insensitive root matching (deduplicated, so a + * double root written once or twice both pass) with 1e-6 tolerance. + * - `number`: single value; expression input (`1/2`, `16*pi`) accepted. + * - `assignments`: labeled ("x = 2, y = 3", any order) or unlabeled + * ordered ("2, 3") variable values — swapped labeled values fail. + * - `expression`: strips `+ C` and assignment prefixes, then delegates + * to {@link checkEquivalence}. + */ +export function checkGradedAnswer(studentAnswer: string, graded: GradedAnswer): GradedCheckResult { + switch (graded.kind) { + case 'solutions': { + const values = parseSolutionList(studentAnswer); + if (!values) return { correct: false, method: 'numeric' }; + const correct = multisetMatch( + dedupe(values, SOLUTION_TOLERANCE), + dedupe(graded.values, SOLUTION_TOLERANCE), + SOLUTION_TOLERANCE, + ); + return { correct, method: 'numeric' }; + } + + case 'number': { + const value = evalConstant(stripLeadingAssignment(studentAnswer.trim())); + if (value === null) return { correct: false, method: 'numeric' }; + const tolerance = graded.tolerance ?? SOLUTION_TOLERANCE; + return { correct: approxEqual(value, graded.value, tolerance), method: 'numeric' }; + } + + case 'assignments': { + const parts = studentAnswer + .split(/\band\b|[,;]/i) + .map((part) => part.trim()) + .filter((part) => part.length > 0); + if (parts.length !== graded.variables.length) { + return { correct: false, method: 'numeric' }; + } + + const labeled = parts.every((part) => /^[A-Za-z][A-Za-z0-9_]*\s*=/.test(part)); + if (labeled) { + const assignments = new Map(); + for (const part of parts) { + const match = /^([A-Za-z][A-Za-z0-9_]*)\s*=\s*(.+)$/.exec(part); + if (!match || match[1] === undefined || match[2] === undefined) { + return { correct: false, method: 'numeric' }; + } + const value = evalConstant(match[2]); + if (value === null || assignments.has(match[1])) { + return { correct: false, method: 'numeric' }; + } + assignments.set(match[1], value); + } + const correct = graded.variables.every((name, i) => { + const got = assignments.get(name); + const expected = graded.values[i]; + return ( + got !== undefined && + expected !== undefined && + approxEqual(got, expected, SOLUTION_TOLERANCE) + ); + }); + return { correct, method: 'numeric' }; + } + + // Unlabeled: ordered interpretation in declared variable order. + const values = parts.map((part) => evalConstant(part)); + const correct = + values.every((value): value is number => value !== null) && + values.every((value, i) => { + const expected = graded.values[i]; + return expected !== undefined && approxEqual(value, expected, SOLUTION_TOLERANCE); + }); + return { correct, method: 'numeric' }; + } + + case 'expression': { + const normalized = stripConstantOfIntegration( + stripLeadingAssignment(studentAnswer.trim()), + ).trim(); + if (normalized.length === 0) return { correct: false, method: 'symbolic' }; + const result = checkEquivalence(normalized, graded.expression); + return { correct: result.equivalent, method: result.method }; + } + + default: + return { correct: false, method: 'numeric' }; + } +} + +/** + * Normalize a free-form answer for expression comparison: trims, + * strips a leading assignment prefix and a trailing `+ C`. + * Exposed for grading call-sites (e.g. server actions). + */ +export function normalizeAnswerExpression(input: string): string { + return stripConstantOfIntegration(stripLeadingAssignment(input.trim())).trim(); +} diff --git a/packages/math-engine/src/equivalence/index.ts b/packages/math-engine/src/equivalence/index.ts new file mode 100644 index 00000000..6f347e70 --- /dev/null +++ b/packages/math-engine/src/equivalence/index.ts @@ -0,0 +1,6 @@ +/** + * Equivalence checking module + * CAS-backed expression equivalence and graded-answer checking + */ + +export * from './equivalence'; From 01bd071cf0a54fa628d2472f38d7d48ac9d0cc32 Mon Sep 17 00:00:00 2001 From: ABCrimson <231791317+ABCrimson@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:16:36 -0500 Subject: [PATCH 3/5] feat(web): infinite drill mode + Check-my-work CAS verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DrillMode component: seed-deterministic problems from the template engine, graded via checkGradedAnswer (equivalent forms accepted), commonMistakes explanations on wrong answers, progressive hints, per-problem timer (useEffectEvent — no legacy ref hacks), running tally chips, Copy-link sharing, template picker grouped by category - Practice page: ?mode=drill&template=…&seed=… deep links via useSearchParams (Suspense-wrapped) with native window.history.replaceState/pushState shallow URL updates; a missing seed is minted (crypto.getRandomValues) straight into the URL so every drill link reproduces its exact problem; Infinite Drill entry card in the config UI - Session persistence: startPracticeSession on first checked answer, completePracticeSession on End drill with new optional correctCount + topicSlug fields feeding TopicProgress (generated problems write NO per-attempt rows — Attempt.problemId is a required FK to Problem); graceful sign-in messaging when unauthenticated - CheckWorkPanel ('Check my work'): verifies any equivalent form against a canonical expression, reporting verified-symbolically/numerically, parse-error and inconclusive states; embedded in InteractiveSolver (gated on isValidExpression) and SolverPanel (new finalRaw on both compute paths; equations use the zero-form LHS-RHS so factorings verify) - submitAnswer grading upgraded in place (contract unchanged): exact match first, then conservative CAS equivalence in try/catch - i18n: 21 practice.drill.* + 11 checkWork.* keys fully translated in en/ru/es/uk/de/fr/ja/zh (identical structure, ICU plurals per locale) Co-Authored-By: Claude Fable 5 --- apps/web/app/[locale]/practice/page.tsx | 134 +++- apps/web/app/actions/practice.ts | 31 + apps/web/app/actions/problems.ts | 35 +- .../components/calculator/solver-panel.tsx | 36 ++ apps/web/components/math/check-work-panel.tsx | 160 +++++ apps/web/components/math/drill-mode.tsx | 610 ++++++++++++++++++ .../components/math/interactive-solver.tsx | 17 + apps/web/lib/validations/learning.ts | 3 + apps/web/messages/de.json | 38 +- apps/web/messages/en.json | 38 +- apps/web/messages/es.json | 38 +- apps/web/messages/fr.json | 38 +- apps/web/messages/ja.json | 38 +- apps/web/messages/ru.json | 38 +- apps/web/messages/uk.json | 38 +- apps/web/messages/zh.json | 38 +- 16 files changed, 1316 insertions(+), 14 deletions(-) create mode 100644 apps/web/components/math/check-work-panel.tsx create mode 100644 apps/web/components/math/drill-mode.tsx diff --git a/apps/web/app/[locale]/practice/page.tsx b/apps/web/app/[locale]/practice/page.tsx index 5edaa78d..559d1f66 100644 --- a/apps/web/app/[locale]/practice/page.tsx +++ b/apps/web/app/[locale]/practice/page.tsx @@ -7,11 +7,13 @@ import { getProblemsByTopic, type Problem, } from '@nextcalc/math-engine/problems'; +import { randomSeedString } from '@nextcalc/math-engine/problems/templates'; import { Play, Settings, Target, Timer, TrendingUp, Zap } from 'lucide-react'; import { m, useReducedMotion } from 'motion/react'; +import { useSearchParams } from 'next/navigation'; import { useTranslations } from 'next-intl'; import type { CSSProperties } from 'react'; -import { useActionState, useCallback, useEffect, useRef, useState } from 'react'; +import { Suspense, useActionState, useCallback, useEffect, useRef, useState } from 'react'; import type { PracticeAttemptResult, PracticeSessionResult, @@ -23,6 +25,7 @@ import { startPracticeSession, } from '@/app/actions/practice'; import type { ActionResult } from '@/app/actions/problems'; +import { DrillMode } from '@/components/math/drill-mode'; import { type PracticeMetrics, PracticeMode } from '@/components/math/practice-mode'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -291,8 +294,9 @@ function StatCard({ data }: { data: StatCardData }) { * - Semantic form structure with associated labels * - Screen reader announcements via aria-live on the empty-state region */ -export default function PracticePage() { +function PracticePageInner() { const t = useTranslations('practice'); + const searchParams = useSearchParams(); const [isConfiguring, setIsConfiguring] = useState(true); const [problems, setProblems] = useState>([]); const [config, setConfig] = useState({ @@ -321,6 +325,49 @@ export default function PracticePage() { const prefersReduced = useReducedMotion() ?? false; + // ── Infinite-drill URL state (?mode=drill&template=…&seed=…) ──────────── + const drillActive = searchParams.get('mode') === 'drill'; + const drillTemplate = searchParams.get('template') ?? 'linear-equation-basic'; + const drillSeed = searchParams.get('seed'); + + // Mint a seed into the URL when drill mode is entered without one, so the + // address bar is always shareable and reproduces this exact problem. + useEffect(() => { + if (drillActive && !drillSeed) { + const params = new URLSearchParams(searchParams.toString()); + params.set('template', drillTemplate); + params.set('seed', randomSeedString()); + window.history.replaceState(null, '', `?${params.toString()}`); + } + }, [drillActive, drillSeed, drillTemplate, searchParams]); + + // Shallow URL updates via the native History API (Next 16 keeps + // useSearchParams in sync) — no server round-trip per problem. + const handleDrillChange = (next: { templateId: string; seed: string }) => { + const params = new URLSearchParams(searchParams.toString()); + params.set('mode', 'drill'); + params.set('template', next.templateId); + params.set('seed', next.seed); + window.history.replaceState(null, '', `?${params.toString()}`); + }; + + const handleEnterDrill = () => { + const params = new URLSearchParams(searchParams.toString()); + params.set('mode', 'drill'); + params.set('template', drillTemplate); + params.set('seed', randomSeedString()); + window.history.pushState(null, '', `?${params.toString()}`); + }; + + const handleExitDrill = () => { + const params = new URLSearchParams(searchParams.toString()); + params.delete('mode'); + params.delete('template'); + params.delete('seed'); + const query = params.toString(); + window.history.pushState(null, '', query ? `?${query}` : window.location.pathname); + }; + // Load problems from math-engine's in-memory problem database. // Applies topic and difficulty filters from the session config, then // randomly samples up to questionCount problems so every session feels fresh. @@ -410,6 +457,25 @@ export default function PracticePage() { [completeSessionAction], ); + // ── Infinite drill mode ───────────────────────────────────────────────── + if (drillActive) { + return ( +
+ +
+ {drillSeed && ( + + )} +
+
+ ); + } + // ── Empty state (no matching problems) ───────────────────────────────────── if (!isConfiguring) { if (problems.length === 0) { @@ -520,6 +586,58 @@ export default function PracticePage() { {/* Configuration Form */}
+ {/* Infinite Drill entry */} + + + +
+ +
+
{t('drill.title')}
+
{t('drill.description')}
+
+
+ +
+
+
+ ); } + +/** + * Page shell: useSearchParams (drill deep-links) requires a Suspense + * boundary for static prerendering. + */ +export default function PracticePage() { + return ( + + + + ); +} diff --git a/apps/web/app/actions/practice.ts b/apps/web/app/actions/practice.ts index 888ad173..6837f677 100644 --- a/apps/web/app/actions/practice.ts +++ b/apps/web/app/actions/practice.ts @@ -199,6 +199,37 @@ export async function completePracticeSession( }, }); + // Topic-progress wiring for drill sessions (generated problems have no + // Attempt rows, so aggregates land here). Best-effort: silently skipped + // when no Topic row matches the slug. + if (data.correctCount !== undefined && data.correctCount > 0 && data.topicSlug) { + const topic = await prisma.topic.findUnique({ + where: { slug: data.topicSlug }, + select: { id: true }, + }); + if (topic) { + await prisma.topicProgress.upsert({ + where: { + userProgressId_topicId: { + userProgressId: practiceSession.userProgressId, + topicId: topic.id, + }, + }, + update: { + problemsSolved: { increment: data.correctCount }, + timeSpent: { increment: data.totalTime }, + lastPracticed: new Date(), + }, + create: { + userProgressId: practiceSession.userProgressId, + topicId: topic.id, + problemsSolved: data.correctCount, + timeSpent: data.totalTime, + }, + }); + } + } + return { success: true, data: { diff --git a/apps/web/app/actions/problems.ts b/apps/web/app/actions/problems.ts index 87aa4faa..7f631878 100644 --- a/apps/web/app/actions/problems.ts +++ b/apps/web/app/actions/problems.ts @@ -7,6 +7,7 @@ * Each action validates input via Zod, checks auth, and returns a typed result. */ +import { checkEquivalence, normalizeAnswerExpression } from '@nextcalc/math-engine/equivalence'; import { revalidatePath } from 'next/cache'; import { auth } from '@/auth'; import { prisma } from '@/lib/prisma'; @@ -16,6 +17,34 @@ import { HintRequestSchema, } from '@/lib/validations/learning'; +/** + * Grade a submitted answer against an expected test-case value. + * Exact-match first (original behavior preserved), then CAS equivalence + * so mathematically equivalent forms (e.g. "x=2" vs "2", "(x-2)*(x-3)" + * vs "x^2-5*x+6", "1/2" vs "0.5") are accepted. The equivalence checker + * is conservative — it never claims equivalence it cannot support — and + * unparseable expected values degrade gracefully to exact matching. + */ +function answerMatchesExpected(answer: string, expected: string): boolean { + // (a) Trimmed case-insensitive equality — the original grading rule + if (expected.trim().toLowerCase() === answer.trim().toLowerCase()) { + return true; + } + // (b)+(c) Numeric/symbolic equivalence (checkEquivalence covers both: + // constant expressions compare numerically, variable expressions via + // symbolic simplification with seeded numeric probing). + try { + const normalizedAnswer = normalizeAnswerExpression(answer); + const normalizedExpected = normalizeAnswerExpression(expected); + if (normalizedAnswer.length === 0 || normalizedExpected.length === 0) { + return false; + } + return checkEquivalence(normalizedAnswer, normalizedExpected).equivalent; + } catch { + return false; + } +} + // --------------------------------------------------------------------------- // Shared types // --------------------------------------------------------------------------- @@ -80,12 +109,10 @@ export async function submitAnswer( return { success: false, error: 'Problem not found' }; } - // Validate answer against test cases + // Validate answer against test cases (exact match or CAS-equivalent form) const isCorrect = problem.testCases.length > 0 - ? problem.testCases.some( - (tc) => tc.expected.trim().toLowerCase() === data.answer.trim().toLowerCase(), - ) + ? problem.testCases.some((tc) => answerMatchesExpected(data.answer, tc.expected)) : true; // Get or create user progress diff --git a/apps/web/components/calculator/solver-panel.tsx b/apps/web/components/calculator/solver-panel.tsx index 1793c683..ef8275ce 100644 --- a/apps/web/components/calculator/solver-panel.tsx +++ b/apps/web/components/calculator/solver-panel.tsx @@ -17,6 +17,7 @@ import { import { AnimatePresence, m } from 'motion/react'; import type { KeyboardEvent } from 'react'; import { useCallback, useId, useState, useTransition } from 'react'; +import { CheckWorkPanel } from '@/components/math/check-work-panel'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -84,6 +85,8 @@ interface RenderedSolution { readonly steps: ReadonlyArray; readonly finalLatex: string; readonly timeMs: number; + /** Raw parseable expression of the result — enables "Check my work" */ + readonly finalRaw?: string; } // ============================================================================ @@ -1064,6 +1067,9 @@ function ResultsPanel({ solution, onCopyAnswer, copied }: ResultsPanelProps) {
+ + {/* "Check my work" — verify an equivalent form against the answer */} + {solution.finalRaw && } ); } @@ -1229,6 +1235,7 @@ export function SolverPanel() { steps, finalLatex: `${resultLatex} + C`, timeMs: elapsed, + finalRaw: resultStr, }); return; } @@ -1260,12 +1267,41 @@ export function SolverPanel() { // Build final answer LaTeX const finalLatex = buildFinalLatex(stepSolution.answer, varName); + // Raw parseable expression for the "Check my work" panel. + // For equations the canonical is the zero-form LHS − RHS (so any + // equivalent factoring/expansion of the polynomial verifies); + // for simplify/derivative it is the resulting expression itself. + let finalRaw: string | null = null; + try { + if (mode === 'equation') { + const eqParts = validated.split('='); + const lhs = eqParts[0]?.trim(); + const rhs = eqParts[1]?.trim(); + if (eqParts.length === 2 && lhs && rhs) { + finalRaw = rhs === '0' ? lhs : `(${lhs}) - (${rhs})`; + } + } else { + const answer = stepSolution.answer; + if ( + answer !== null && + typeof answer === 'object' && + !Array.isArray(answer) && + '_brand' in answer + ) { + finalRaw = symbolic.astToString(answer); + } + } + } catch { + finalRaw = null; + } + setSolution({ problem: validated, mode, steps: renderedSteps, finalLatex, timeMs: elapsed, + ...(finalRaw ? { finalRaw } : {}), }); } catch (err) { const msg = err instanceof Error ? err.message : 'An unexpected error occurred.'; diff --git a/apps/web/components/math/check-work-panel.tsx b/apps/web/components/math/check-work-panel.tsx new file mode 100644 index 00000000..2822c96a --- /dev/null +++ b/apps/web/components/math/check-work-panel.tsx @@ -0,0 +1,160 @@ +'use client'; + +import type { EquivalenceResult } from '@nextcalc/math-engine/equivalence'; +import { AlertCircle, CheckCircle2, Loader2, ShieldCheck, XCircle } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import type { KeyboardEvent } from 'react'; +import { useId, useState, useTransition } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { cn } from '@/lib/utils'; + +export interface CheckWorkPanelProps { + /** Canonical parseable expression the student's input is verified against */ + canonical: string; + /** Optional heading override (defaults to the checkWork.title message) */ + label?: string; +} + +/** Visual state derived from an equivalence result */ +type PanelStatus = 'equivalent' | 'not-equivalent' | 'parse-error' | 'inconclusive'; + +function statusFor(result: EquivalenceResult): PanelStatus { + if (result.equivalent) return 'equivalent'; + if (result.reason === 'parse-error') return 'parse-error'; + if (result.reason === 'inconclusive') return 'inconclusive'; + return 'not-equivalent'; +} + +const STATUS_STYLES: Record = { + equivalent: 'bg-green-500/10 border-green-500/30', + 'not-equivalent': 'bg-red-500/10 border-red-500/30', + 'parse-error': 'bg-amber-500/10 border-amber-500/30', + inconclusive: 'bg-amber-500/10 border-amber-500/30', +}; + +/** + * CheckWorkPanel — "Check my work" self-verification widget. + * + * The student enters their own form of an answer and the panel verifies + * mathematical equivalence against the canonical expression via the + * math-engine equivalence checker (symbolic diff-is-zero with a + * deterministic seeded numeric-probing fallback). This is a study aid, + * separate from graded submission — it never records an attempt. + */ +export function CheckWorkPanel({ canonical, label }: CheckWorkPanelProps) { + const t = useTranslations('checkWork'); + const [input, setInput] = useState(''); + const [result, setResult] = useState(null); + const [isPending, startTransition] = useTransition(); + const inputId = useId(); + const statusId = useId(); + + const handleCheck = () => { + const candidate = input.trim(); + if (candidate.length === 0) return; + startTransition(async () => { + // Dynamic import keeps the CAS out of the initial bundle + const { checkEquivalence, normalizeAnswerExpression } = await import( + '@nextcalc/math-engine/equivalence' + ); + setResult(checkEquivalence(normalizeAnswerExpression(candidate), canonical)); + }); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter' && !isPending) { + event.preventDefault(); + handleCheck(); + } + }; + + const status = result ? statusFor(result) : null; + + return ( +
+
+
+

{t('description')}

+ +
+ + { + setInput(event.target.value); + setResult(null); + }} + onKeyDown={handleKeyDown} + placeholder={t('inputPlaceholder')} + className="font-mono text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring" + spellCheck={false} + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + aria-describedby={result ? statusId : undefined} + /> + +
+ + {result && status && ( +
+ {status === 'equivalent' && ( +
+ )} +
+ ); +} diff --git a/apps/web/components/math/drill-mode.tsx b/apps/web/components/math/drill-mode.tsx new file mode 100644 index 00000000..60ee81d9 --- /dev/null +++ b/apps/web/components/math/drill-mode.tsx @@ -0,0 +1,610 @@ +'use client'; + +import { checkGradedAnswer } from '@nextcalc/math-engine/equivalence'; +import { + allTemplates, + type Mistake, + type ProblemInstance, + type ProblemTemplate, + randomSeedString, + registerAllTemplates, + templateEngine, +} from '@nextcalc/math-engine/problems/templates'; +import { + AlertCircle, + CheckCircle2, + Flame, + Lightbulb, + Link2, + ListChecks, + RefreshCw, + Save, + Target, + Timer, + XCircle, +} from 'lucide-react'; +import { m, useReducedMotion } from 'motion/react'; +import { useTranslations } from 'next-intl'; +import type { KeyboardEvent, ReactNode } from 'react'; +import { useActionState, useEffect, useEffectEvent, useRef, useState } from 'react'; +import { + completePracticeSession, + type PracticeSessionResult, + type StartSessionResult, + startPracticeSession, +} from '@/app/actions/practice'; +import type { ActionResult } from '@/app/actions/problems'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { MathRenderer } from '@/components/ui/math-renderer'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { cn } from '@/lib/utils'; + +// Idempotent (Map.set) — safe on repeated module evaluation and under +// React StrictMode; generation itself is pure and seed-local. +registerAllTemplates(); + +export interface DrillModeProps { + /** Template id from the URL (?template=…) */ + templateId: string; + /** Seed from the URL (?seed=…) — same seed always reproduces the problem */ + seed: string; + /** Called when the drill needs a new URL state (new problem / template) */ + onSeedChange: (next: { templateId: string; seed: string }) => void; + /** Called when the user exits the drill back to session setup */ + onExit: () => void; +} + +interface DrillTally { + attempted: number; + correct: number; + streak: number; + bestStreak: number; +} + +const DIFFICULTY_VARIANTS = ['beginner', 'intermediate', 'advanced', 'expert', 'research'] as const; + +/** "linear-equation-basic" → "Linear Equation Basic" */ +function humanizeId(id: string): string { + return id + .split('-') + .map((word) => (word.length > 0 ? word[0]?.toUpperCase() + word.slice(1) : word)) + .join(' '); +} + +/** Group all registered templates by category (stable module-level data). */ +const TEMPLATE_GROUPS: ReadonlyArray<{ category: string; templates: ProblemTemplate[] }> = (() => { + const groups = new Map(); + for (const template of allTemplates) { + const list = groups.get(template.category); + if (list) { + list.push(template); + } else { + groups.set(template.category, [template]); + } + } + return [...groups.entries()].map(([category, templates]) => ({ category, templates })); +})(); + +/** + * Render template text with embedded $…$ / $$…$$ LaTeX via KaTeX. + * The splitter keeps nested braces intact because the delimiter is `$`. + */ +function MathText({ text, className }: { text: string; className?: string }) { + const segments = text.split(/(\$\$[\s\S]+?\$\$|\$[^$]+\$)/g); + return ( + + {segments.map((segment, index) => { + const key = `${index}-${segment}`; + if (segment.startsWith('$$') && segment.endsWith('$$')) { + return ; + } + if (segment.startsWith('$') && segment.endsWith('$') && segment.length > 1) { + return ; + } + return {segment}; + })} + + ); +} + +/** Small stat chip for the running tally row. */ +function StatChip({ icon, children }: { icon: ReactNode; children: ReactNode }) { + return ( + + {icon} + {children} + + ); +} + +// ─── Per-problem card (remounted via key on every new seed) ────────────────── + +interface DrillProblemCardProps { + instance: ProblemInstance; + template: ProblemTemplate; + /** Reports the FIRST check of this problem: (correct, timeSpentSeconds) */ + onAnswered: (correct: boolean, timeSpentSeconds: number) => void; +} + +function DrillProblemCard({ instance, template, onAnswered }: DrillProblemCardProps) { + const t = useTranslations('practice'); + const [input, setInput] = useState(''); + const [feedback, setFeedback] = useState<{ correct: boolean; mistake: Mistake | null } | null>( + null, + ); + const [hintsShown, setHintsShown] = useState(0); + const [elapsed, setElapsed] = useState(0); + const startRef = useRef(Date.now()); + const recordedRef = useRef(false); + + // Tick the per-problem timer; freeze once the first answer is checked. + const onTick = useEffectEvent(() => { + if (!recordedRef.current) { + setElapsed(Math.round((Date.now() - startRef.current) / 1000)); + } + }); + useEffect(() => { + const id = setInterval(() => { + onTick(); + }, 1000); + return () => clearInterval(id); + }, []); + + const handleCheck = () => { + const trimmed = input.trim(); + if (trimmed.length === 0) return; + + const correct = instance.graded + ? checkGradedAnswer(trimmed, instance.graded).correct + : trimmed.toLowerCase() === instance.solution.answer.trim().toLowerCase(); + + const mistake = correct + ? null + : (template.commonMistakes + .map((detector) => detector(instance.parameters, trimmed)) + .find((found): found is Mistake => found !== null) ?? null); + + setFeedback({ correct, mistake }); + + if (!recordedRef.current) { + recordedRef.current = true; + onAnswered(correct, Math.max(1, Math.round((Date.now() - startRef.current) / 1000))); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleCheck(); + } + }; + + return ( + + +
+ + + + +
+
+ + {/* Statement */} +
+ +
+ + {/* Answer input */} +
+ +
+ { + setInput(event.target.value); + setFeedback(null); + }} + onKeyDown={handleKeyDown} + placeholder={t('drill.answerPlaceholder')} + className="font-mono focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring" + spellCheck={false} + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + /> + +
+
+ + {/* Feedback */} + {feedback && ( +
+ {feedback.correct ? ( +
+ )} + + {/* Progressive hints */} +
+
+ + {hintsShown > 0 && ( + + {t('drill.hintCost', { count: hintsShown })} + + )} +
+ {hintsShown > 0 && ( +
    + {instance.hints.slice(0, hintsShown).map((hint, index) => ( +
  1. + {index + 1}. + +
  2. + ))} +
+ )} +
+
+
+ ); +} + +// ─── Drill shell (tally, session persistence, URL state) ──────────────────── + +/** + * DrillMode — infinite randomized practice. + * + * Problems are generated deterministically from (templateId, seed); the + * seed lives in the URL so every problem is shareable and reproducible. + * Grading uses the CAS-backed graded answer (equivalent forms accepted). + * Session aggregates persist via startPracticeSession / + * completePracticeSession — per-attempt rows are intentionally NOT + * written for generated problems (Attempt.problemId is a required FK to + * the static Problem table). + */ +export function DrillMode({ templateId, seed, onSeedChange, onExit }: DrillModeProps) { + const t = useTranslations('practice'); + const prefersReduced = useReducedMotion() ?? false; + + const [tally, setTally] = useState({ + attempted: 0, + correct: 0, + streak: 0, + bestStreak: 0, + }); + const [copied, setCopied] = useState(false); + const totalTimeRef = useRef(0); + const sessionIdRef = useRef(null); + const sessionRequestedRef = useRef(false); + + const [startState, startAction] = useActionState, FormData>( + startPracticeSession, + { success: false }, + ); + const [completeState, completeAction] = useActionState< + ActionResult, + FormData + >(completePracticeSession, { success: false }); + + useEffect(() => { + if (startState.success && startState.data?.sessionId) { + sessionIdRef.current = startState.data.sessionId; + } + }, [startState]); + + const template = templateEngine.getTemplate(templateId); + let instance: ProblemInstance | null = null; + if (template) { + try { + instance = templateEngine.generate(templateId, seed); + } catch { + instance = null; + } + } + + const handleAnswered = (correct: boolean, timeSpentSeconds: number) => { + totalTimeRef.current += timeSpentSeconds; + setTally((prev) => { + const streak = correct ? prev.streak + 1 : 0; + return { + attempted: prev.attempted + 1, + correct: prev.correct + (correct ? 1 : 0), + streak, + bestStreak: Math.max(prev.bestStreak, streak), + }; + }); + + // Create the practice session on the first checked answer + if (!sessionRequestedRef.current && template) { + sessionRequestedRef.current = true; + const fd = new FormData(); + fd.set('topic', template.category); + fd.set('questionCount', '1'); + fd.set('adaptive', 'false'); + startAction(fd); + } + }; + + const handleGenerateAnother = () => { + onSeedChange({ templateId, seed: randomSeedString() }); + }; + + const handleTemplateChange = (nextTemplateId: string) => { + onSeedChange({ templateId: nextTemplateId, seed: randomSeedString() }); + }; + + const handleCopyLink = () => { + navigator.clipboard + .writeText(window.location.href) + .then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }) + .catch(() => { + // Clipboard permission denied — silent fail + }); + }; + + const handleEndDrill = () => { + if (!sessionIdRef.current || !template || tally.attempted === 0) return; + const attempted = Math.max(1, tally.attempted); + const fd = new FormData(); + fd.set('sessionId', sessionIdRef.current); + fd.set('score', String(tally.correct / attempted)); + fd.set('accuracy', String(tally.correct / attempted)); + fd.set('bestStreak', String(tally.bestStreak)); + fd.set('totalTime', String(Math.round(totalTimeRef.current))); + fd.set('pointsEarned', String(10 * tally.correct)); + fd.set('correctCount', String(tally.correct)); + fd.set('topicSlug', template.category.toLowerCase()); + completeAction(fd); + }; + + const sessionErrored = + (sessionRequestedRef.current && !startState.success && Boolean(startState.error)) || + (!completeState.success && Boolean(completeState.error)); + + return ( + + {/* Header */} +
+

+ + {t('drill.title')} + +

+

{t('drill.description')}

+
+ + {/* Template + seed controls */} + + +
+
+ + +
+
+ +
+ + {seed} + + {template && ( + + {'★'.repeat(template.difficulty)} + + )} +
+
+
+ + {/* Tally chips */} +
+ + + 0 ? 'text-orange-500' : 'text-muted-foreground', + )} + aria-hidden="true" + /> + } + > + {t('drill.streak', { count: tally.streak })} + +
+
+
+ + {/* Problem (remounts per template+seed, resetting per-problem state) */} + {instance && template ? ( + + ) : ( + + + + + )} + + {/* Actions */} +
+ + + + +
+ + {/* Session persistence status */} + {completeState.success && ( +

+

+ )} + {!completeState.success && sessionErrored && ( +

+

+ )} +
+ ); +} diff --git a/apps/web/components/math/interactive-solver.tsx b/apps/web/components/math/interactive-solver.tsx index 84432e45..e73cc9ec 100644 --- a/apps/web/components/math/interactive-solver.tsx +++ b/apps/web/components/math/interactive-solver.tsx @@ -1,5 +1,6 @@ 'use client'; +import { isValidExpression } from '@nextcalc/math-engine/parser'; import type { Hint, Problem, SolutionStep } from '@nextcalc/math-engine/problems'; import { ArrowRight, @@ -18,6 +19,7 @@ import { } from 'lucide-react'; import { AnimatePresence, m } from 'motion/react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { CheckWorkPanel } from '@/components/math/check-work-panel'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -119,6 +121,14 @@ export function InteractiveSolver({ const potentialPoints = Math.max(0, problem.points - hintPenalty); + // "Check my work" canonical: the stored answer with a leading "x =" + // stripped — only shown when it parses as a math expression (prose + // answers would make the checker meaningless). + const checkWorkCanonical = String(problem.solution.answer) + .replace(/^[a-zA-Z]\s*=\s*/, '') + .trim(); + const showCheckWork = checkWorkCanonical.length > 0 && isValidExpression(checkWorkCanonical); + // Format time display const formatTime = (seconds: number) => { const mins = Math.floor(seconds / 60); @@ -288,6 +298,13 @@ export function InteractiveSolver({ )} + {/* Self-verification aid — separate from the graded submit */} + {showCheckWork && ( +
+ +
+ )} +
{/* Session persistence status */} - {completeState.success && ( + {savedThisSegment && (

)} - {!completeState.success && sessionErrored && ( + {!savedThisSegment && sessionErrored && (