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.

Conclusion

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)