SVG Diagrams and Parametric Generators: Testing 358 Questions Across 200 Seeds

"Calculate the hypotenuse BC of the right triangle at A. AB = 12 cm, AC = 9 cm, BC = ?" The student reads the question, looks at the diagram… and the diagram says AB = 3 cm. Different triangle. The values in the question are randomly generated, but the diagram is static. This kind of bug doesn't crash anything — it just makes an educational tool unusable.

Radar College is a quiz platform for French middle school exams. After building the React architecture and migrating to TypeScript, one major challenge remained: moving from static questions to parametric ones — with SVG diagrams that match the generated values. And more importantly, finding a way to test all of it without going insane.

The static question problem

The initial version had ~300 hardcoded questions. Each one: a text prompt, 4 options, a hint. It works, but after 3 attempts the student recognizes the questions. Randomization only affected the pick order and answer shuffling — not the actual numerical values.

For math and physics, this is a dealbreaker. A student who memorizes "the answer is 25 cm" hasn't learned anything. The numbers need to change on every attempt — and the distractors (wrong answers) need to be computed from the correct answer to stay plausible. No random garbage: wrong answers should correspond to typical mistakes (forgetting the square root, flipping a sign, adding instead of multiplying).

Anatomy of a generator

Each parametric question is a gen(rnd) function that receives a seeded PRNG and returns an object { q, options, correct, hint }. The PRNG is a Mulberry32: 32-bit, deterministic, fast. The same seed always produces the same question — which means we only store the seed in the history and reconstruct the exact problem for review.

// Pythagorean theorem generator — compute the hypotenuse
{ key:'pyt-1', gen: (rnd) => {
  const TRIPLETS = [[3,4,5],[5,12,13],[8,15,17],[7,24,25],[6,8,10]];
  const [a0, b0, c0] = TRIPLETS[Math.floor(rnd() * TRIPLETS.length)];
  const k = 1 + Math.floor(rnd() * 3); // multiplier 1..3
  const [a, b, c] = [a0*k, b0*k, c0*k];

  return {
    q: <>Calculate the hypotenuse BC:
       <TriangleRectangle ab={`${a} cm`} ac={`${b} cm`} bc="?" /></>,
    options: [`${c} cm`, `${a+b} cm`, `${a*a+b*b} cm`, `${Math.abs(a-b)} cm`],
    correct: 0,
    hint: `BC² = ${a}² + ${b}² = ${a*a+b*b} → BC = ${c} cm.`,
  };
}}

Three things to note. First, the pool of Pythagorean triplets guarantees the hypotenuse is an integer — no √41 ≈ 6.403 in a middle school quiz. Second, the multiplier k gives varied values without leaving integer territory. Third, the distractors aren't random: a+b (classic mistake: adding instead of Pythagoras), a²+b² (forgot the square root), |a-b| (subtraction by reflex).

An SVG kit that follows the values

The <TriangleRectangle> component above isn't decorative. It's a React component that receives values as props and renders the matching diagram — with labeled sides, a marked right angle, and a "?" on the measurement to find.

// _svg-kit.tsx — Parametric right triangle
function TriangleRectangle({ ab, ac, bc }: {
  ab?: string | number;
  ac?: string | number;
  bc?: string | number;
} = {}) {
  const lAB = ab !== undefined ? `AB = ${ab}` : 'AB';
  const lAC = ac !== undefined ? `AC = ${ac}` : 'AC';
  const lBC = bc !== undefined ? `BC = ${bc}` : 'BC (hypotenuse)';

  return (
    <svg viewBox="0 0 250 160" role="img"
         aria-label="Right triangle at A">
      <polygon points="50,30 50,130 200,130"
               fill="rgba(199,138,29,0.08)"
               stroke="currentColor" strokeWidth={1.8} />
      <rect x={50} y={118} width={12} height={12}
            stroke="currentColor" />  {/* right angle */}
      <text x={40} y={85} fill="#b45309">{lAB}</text>
      <text x={125} y={148} fill="#b45309">{lAC}</text>
      <text x={135} y={72} fill="#b45309">{lBC}</text>
    </svg>
  );
}

The same pattern applies across the kit: ConfigThales (6 props for segments AM, AB, AN, AC, MN, BC), TriangleTrigo (angle, opposite/adjacent/hypotenuse sides), GrapheAffine (slope, y-intercept). When the generator picks random values, it passes them to the SVG component — the diagram always shows the same numbers as the question.

Electrical circuits and 3D volumes

The kit goes beyond geometry. For 8th-grade physics, electricity questions need circuit diagrams. Instead of static PNG images, I built composable SVG primitives:

// Primitives: Fil, Pile, Resistance, Lampe, Amperemetre, Voltmetre
// Compositions: CircuitSerie, CircuitParallele, CircuitCourtCircuit…

function CircuitSerie() {
  return (
    <svg viewBox="0 0 240 115" role="img"
         aria-label="Series circuit">
      <Fil points={[[30,85],[30,30],[210,30],[210,85],[30,85]]} />
      <Pile cx={30} cy={58} />
      <Resistance cx={100} cy={30} label="R₁" />
      <Resistance cx={170} cy={30} label="R₂" />
      <text x={120} y={105} fill="#b45309">
        same I everywhere · U = U₁ + U₂
      </text>
    </svg>
  );
}

A <Fil> draws a polyline between points. A <Resistance> draws a rectangle with an optional label. A <Mesureur> draws a circle with a letter inside — "A" for ammeter, "V" for voltmeter. Compositions assemble these building blocks into complete circuits with annotated formulas.

For volumes (7th-grade math), same approach: Cube3D, Pave3D, Cylindre3D, Sphere3D, Cone3D components in cavalier perspective. And for geometric transformations (8th-grade math): SymetrieAxiale, SymetrieCentrale, Translation with a stylized F figure and its image. In total, 20 SVG components in a single _svg-kit.tsx file — 300 lines.

The duplicate distractor trap

When distractors are computed, there's a nasty edge case: a distractor can land on the same value as the correct answer. Example: a square with side 4, area = 16, perimeter = 16. If the distractor is "perimeter instead of area", you get 16 twice in the options.

The first instinct would be to re-roll the values. But with a seeded PRNG, you can't discard rolls — it breaks determinism. The solution: a Set of already-used values, and a systematic bump on collisions.

// Anti-duplicate pattern in every gen
const good = computeAnswer(a, b);
const used = new Set([good]);
const opts = [good];

for (const distractor of [wrongSign, wrongFormula, wrongOp]) {
  let v = distractor;
  while (used.has(v)) v += (v >= 0 ? 1 : -1);
  used.add(v);
  opts.push(v);
}

Except this pattern has its own bug. If the boundary is crossed (v <= 0 and we're decrementing), the while loop runs forever. Not theoretical: it happened on 7 generators when certain seeds produced values near zero. The fix: check the bump direction and reverse if heading out of valid space.

200 seeds per gen, in pre-commit

With 358 generators, manually checking that every value combination produces a valid quiz is impossible. I wrote a Node script (test-generators.js) that loads each quiz file through Babel, runs every gen across 200 seeds, and verifies:

  • 4 options present
  • correct in [0, 3]
  • No duplicates in options (after stringification and French decimal formatting)
  • Determinism: same seed → same output (verified on 5 sentinel seeds)
  • Variability: at least 10 distinct outputs across 200 seeds
// test-generators.js — excerpt
function testQuestion(quizKey, domainKey, q) {
  const issues = [];
  if (typeof q.gen !== 'function') return null;
  const SEEDS = 200;
  const outputs = new Set();

  for (let i = 0; i < SEEDS; i++) {
    const seed = (i * 2654435761) >>> 0; // Knuth multiplicative
    const out = q.gen(mulberry32(seed));

    // 4 options
    if (out.options.length !== 4)
      issues.push(`seed ${seed}: ${out.options.length} options`);

    // Duplicates
    const strings = out.options.map(serializeNode);
    if (new Set(strings).size !== strings.length)
      issues.push(`seed ${seed}: duplicates → [${strings.join(' | ')}]`);

    // Variability
    outputs.add(strings.join('¤') + '|' + out.correct);
  }

  if (outputs.size < 10)
    issues.push(`low variability: ${outputs.size}/200`);

  return { quizKey, key: q.key, issues, uniqueOutputs: outputs.size };
}

The script distinguishes bugs (duplicates, correct out of range, missing options) from warnings (low variability). Only bugs fail the pre-commit. Low variability is displayed but doesn't block — some generators have a naturally narrow input space (a "true or false, is this triangle right-angled" quiz only has two possible outputs by design).

Last run result: 358/358 generators, 0 bugs, 8 variability warnings. Pre-commit passes without --no-verify.

JSX serialization, the testing surprise

The biggest technical challenge in the test script wasn't the checks — it was serialization. Quiz options aren't always strings. A fraction renders with <F n={3} d={4} />, an exponent with <sup>, a math symbol with <M>. To compare two options, you need to reduce them to text.

// Serialize a compiled ReactNode (JSX → createElement → object)
function serializeNode(n) {
  if (n == null || n === false) return '';
  if (typeof n === 'string' || typeof n === 'number') return String(n);
  if (Array.isArray(n)) return n.map(serializeNode).join('');
  if (typeof n === 'object' && n.props) {
    const children = n.props.children;
    if (children == null)
      return `<${typeof n.type === 'string' ? n.type : 'C'}/>`;
    return serializeNode(children);
  }
  return '';
}

The script doesn't mount any DOM. It shims React.createElement to return plain objects, then walks down props.children recursively. The SVG kit components are stubbed — a <TriangleRectangle ab="12 cm" /> serializes to a flat string, enough to detect duplicates without mounting a virtual DOM.

French formatting that breaks comparisons

In France, we write 3,5 — not 3.5. The app applies a Frenchification pass on options before display. Problem: the test must reproduce this exact pass, otherwise a post-formatting duplicate goes unnoticed. Example: "3.0" and "3" both become "3" after .replace(/\.0$/, '').

The script applies the same regex as app.tsx before comparing. It's an exact copy — not a reimplementation, not a port, a line-by-line copy. Any divergence between the test and the app would produce false negatives, and that's exactly the kind of bug that would go unnoticed for months.

Key takeaway

The thing I didn't anticipate is that building parametric questions is a constrained combinatorics problem, not a random generation one. Randomness is the easy part. The hard part is guaranteeing that every value combination produces a valid quiz — no duplicates, no division by zero, no negative result when the context is a length, no diagram contradicting the question.

The pre-commit hook that tests 200 seeds per gen caught 13 duplicate bugs and 7 infinite loops I would never have found manually. The cost: a 180-line script and 4 extra seconds per commit. The return on investment is absurd.

Comments (0)