← board

pasmith — random well-defined Object Pascal generator, differentially tested against FPC

Motivation — the blind spot fuzz.sh cannot see

tools/fuzz.sh (v1, [[feature-ir-fuzzer]]) mutates the existing test/test_cross_*.pas corpus and uses a cross-target differential oracle: compile for x86-64 / i386 / aarch64 / arm32 / riscv32, run each, diff stdout. That oracle has a structural blind spot:

A bug in shared IR lowering produces the same wrong answer on every target. All five agree. The divergence is invisible.

Cross-target differencing can only ever catch backend-divergent bugs. It cannot catch a uniformly-wrong lowering — and that is precisely the class the Csmith experience says dominates ("edge cases with assumptions", found in IR, not in the lexer/parser). fuzz.sh's 204-trial / 0-divergence clean run is consistent with the bugs living exactly where its oracle is blind.

An external reference implementation removes the blind spot entirely. For Pascal we have a free, mature, independent one: FPC. Same source, two compilers, diff the output. This is the missing half of the fuzzing story, not a nicer fuzz.sh.

Second gap, orthogonal to the oracle: mutation-of-corpus can only reach shapes near programs we already wrote. A generative smith reaches shapes nobody would write — which is where "assumption" bugs hide by definition.

Why this is tractable (and cheaper than Csmith was)

Csmith's hard 80% is avoiding UB — strict aliasing, signed overflow, sequence points, uninitialized reads, integer-promotion rules. Every one of those is a way for a generated program to be legitimately allowed to differ between compilers, making the divergence a false positive. Pascal deletes most of that surface: no aliasing rules to violate, evaluation order effectively fixed, overflow either defined or trapped ({$Q+}/{$R+}), no integer-promotion swamp. The UB-avoidance work that made Csmith a research project is, in Pascal, a short list.

What Pascal adds that C has no analogue for — and therefore the highest-yield target here: ansistring is a refcounted, copy-on-write, RTL-managed type with lifetime rules. Deeply nested ansistring temporaries across branches, exceptions, and try/finally is where I'd expect the corpse. The C frontend structurally cannot reach it; only a Pascal-shaped generator can.

Design

Generator: typed AST walk, NOT a grammar walk

This is the load-bearing decision. Csmith is not a grammar fuzzer — it's a typed AST generator carrying a live symbol table, emitting only well-typed, in-scope, UB-free code, so every program is runnable and every divergence is a real bug. A grammar-directed generator (Grammarinator + a .g4) produces syntactically valid, semantically dead programs — undeclared identifiers, type mismatches — which exercise the parser and its error paths and nothing below. Since the bugs we're hunting are below, a grammar fuzzer is the wrong instrument. Build the tree, don't parse it; the grammar then comes free.

Csmith invariants to steal verbatim

  1. UB-free by construction — not "usually", by construction. Non-negotiable: a generator that can emit UB makes every divergence suspect and the tool dies of false positives. (fuzz.sh already learned the false-positive lesson the hard way — its first "8 divergences" were all seed-selection artifacts.)
  2. Single checksum output — hash all live variables at exit, print ONE number. Not printed intermediate state. Makes diffing trivial and shrinking robust.
  3. Seeded, reproduciblepasmith --seed N regenerates byte-for-byte; the seed goes in the generated file's header comment.
  4. Every program terminates — bounded loops only, never while on a generated condition. Non-optional: fuzz.sh hung on its first run because a mutation turned a terminating loop infinite. Keep the timeout belt anyway.

Oracles — plural, ranked, all free

  1. FPC (primary — the one that removes the blind spot). fpc -O2 and fpc -O0.
  2. pxx cross-target (the fuzz.sh oracle, reused): 5 targets under QEMU via tools/run_target.sh.
  3. pxx self-differential: -O0 vs -O2 vs -O3. Needs no FPC at all, and is free Track O coverage (new -O3 passes are exactly the kind of thing this catches).

Run all of them, majority vote. A lone dissenter is the bug. This makes triage mechanical instead of a judgment call.

Triage rule — ordered suspicion, NOT dismissal

The prior favours "pasmith emitted something it shouldn't have", so that's where you look first. It is an ordering of investigation, not a verdict — see the selection-effect note below, which is the whole reason this section exists.

observation investigate in this order
FPC rejects the program (compile error) (a) pasmith emitted invalid code — its contract is valid, well-typed {$mode objfpc} only; (b) we're relying on a dialect corner where pxx and FPC legitimately differ (→ a compat-pascal-* finding, not a bug in either); (c) FPC wrongly rejects valid code — a real FPC bug class, rarer but it exists.
FPC compiles; pxx and FPC outputs differ (a) pasmith emitted implementation-defined / UB code — audit the generator's UB-avoidance FIRST, it's the cheapest check and the most common cause; (b) a pxx bug; (c) an FPC bug.
pxx targets disagree with each other Backend/codegen bug (Track A) — same as fuzz.sh today. FPC not involved.
pxx -O0 vs -O2/-O3 disagree Optimizer bug (Track A / Track O lane). FPC not involved.

Do NOT auto-dismiss an FPC failure (user, 2026-07-13). The tempting shortcut — "FPC is battle-tested, therefore it's us, close the case" — is wrong, and wrong for a structural reason, not a charitable one:

That "FPC is ~98% right" prior is a base rate over all Pascal programs humans have ever written. A fuzzer does not sample from that distribution. It deliberately samples the tail nobody has written before — which is precisely the region where FPC's own test suite is thinnest. Conditional on "we hit a shape no human wrote", P(FPC bug) is far higher than FPC's base bug rate. This is not hypothetical: Csmith found real bugs in GCC and LLVM, which are hammered orders of magnitude harder than FPC is.

So the base rate tells you where to look first; it does not tell you where to stop. Every FPC failure gets judgement, not reflex: read the generated code, read the FPC docs / language spec for the construct, check the FPC bugtracker, diff FPC versions if available. Concretely, an FPC failure is only closed as "our bug" once you can point at the specific line pasmith emitted that it shouldn't have — "FPC is probably right" is not a resolution.

An FPC bug is a legitimate, valuable outcome of this tool and should be reported upstream when found. It still has to be earned: shrink it first. Rule of thumb — if the minimized reproducer isn't small and clean enough that you'd be comfortable posting it to the FPC bugtracker, it isn't an FPC bug yet; it's an unfinished investigation.

Corollary — this is what the extra oracles are for. FPC -O0 vs FPC -O2 disagreeing on the same program is an FPC bug with no judgement call needed at all: FPC contradicts itself, pxx isn't even in the room. Run it; it's free.

Feature ladder — ship v1 narrow, widen on evidence

Each rung is independently useful; do not build them all before running.

Shrinking

On any divergence, delta-debug the generated file (delete/simplify statements while the divergence persists) before filing. Non-optional: generated programs are large and unreadable, and an unshrunk reproducer is a ticket nobody picks up. fuzz.sh already has a minimizer — check whether it can be shared rather than rewritten.

Where it lives

tools/pasmith.py (+ a tools/pasmith_run.sh driver, or fold the driver into tools/fuzz.sh as a second generation mode — decide at pickup). Track T file ownership, alongside testmgr.py / twatch.py / fuzz.sh.

Track T charter amendment (implied by this ticket)

Track T's current CLAUDE.md scope is regression infra (testmgr.py, twatch.py, tstate/**). This ticket reads T as "Tools and Testing" (user, 2026-07-13): fuzzing tooling is a tool used for testing, so it belongs to T even though it is not regression testing. Consequence: tools/fuzz.sh — filed and landed under Track A via [[feature-ir-fuzzer]] — moves to Track T file ownership, joining pasmith. This also gives the idle-fuzzing story its natural home: the Track T agent ([[feature-track-t-agent]]) fuzzes in spare cycles and triages findings into the owning lanes, which is exactly the flow it already runs for tstate NEW-REDs. CLAUDE.md's Track T section needs updating to say so — otherwise the next agent re-derives the old, narrower boundary. (Applies to Csmith runs too: same tool-owns/ findings-file-elsewhere split.)

Explicit non-goals

Acceptance

pasmith --seed N deterministically emits a UB-free, terminating, checksum-printing Object Pascal program that FPC compiles without error (that's the contract, and the first thing to prove). A driver compiles each generated program with pxx and FPC, runs both, diffs the checksum, and shrinks on divergence. One real bounded run completed and logged here — clean or not. A clean run is a valid result (same inverted-success-criteria as [[feature-ir-fuzzer]]); a divergence becomes a shrunk reproducer + a ticket in the owning lane + a permanent test/test_*.pas regression test.

Log