Skip to content

Add @mettascript/fuzz: property testing written in MeTTa - #3

Closed
MesTTo wants to merge 49 commits into
mainfrom
feat/metta-fuzz
Closed

Add @mettascript/fuzz: property testing written in MeTTa#3
MesTTo wants to merge 49 commits into
mainfrom
feat/metta-fuzz

Conversation

@MesTTo

@MesTTo MesTTo commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Adds @mettascript/fuzz, a property-testing library written in MeTTa, plus the CLI, engine, and
documentation work that goes with it. Prepared as 2.8.0.

The library

Generation, shrinking, the run loop, the state machines, and the search are rewrite rules in
packages/fuzz/src/metta. TypeScript supplies only what a representation needs: a random source, a
structural key over atoms, and a versioned atom codec.

  • Generators are data rather than functions, which is what lets one declaration be replayed from a seed,
    shrunk, or enumerated. Scalars, text, structure, the combinators, and grammar and type-directed
    generation for producing well-formed terms instead of random trees.
  • Shrinking under a named order, so the smallest form is stable across runs rather than a function of the
    seed, and the result says whether it reached a local minimum or stopped early and why.
  • Exhaustive checking over a choice-vector cursor. A covered domain is reported as verified with its count;
    a domain that does not fit the bound is an incomplete run, never a verified one.
  • Model-based state machines, where a divergence shrinks to the shortest command sequence that separates
    the real system from its model.
  • Bounded reachability, breadth first, over a transition relation that may return several next states. A
    witness is replayed from the initial state before it is reported, and the four answers stay distinct:
    a witness, exhaustion of a finite model, nothing at or below MaxDepth, and a cutoff.

The CLI

metta fuzz and metta reach run the declarations a file carries, not the file's own ! queries, so
discovery cannot become a way to execute whatever else a file would have done. Exit codes make the command
a test gate: 0 pass or definitive answer, 1 property failure, 2 invalid input or corrupt stored data, 3
incomplete run.

--corpus <dir> keeps found counterexamples as text so a later run replays them first, one file per case
named for the digest of its contents. Values go through the versioned codec rather than being printed, so
NaN and -0.0 survive exactly, and a corpus file that cannot be read stops the run instead of quietly
dropping a known failure.

Engine

  • Tail calls carry a fuel budget through the interpreter's trampoline and the compiled impure driver, and a
    chain continues past bound variables. Native nesting is capped, with open atoms exempt. Deep flat
    recursion that used to report StackOverflow now completes.
  • Error payloads no longer count as recursive calls for tabling, which had got let* memoized with keys
    that could never hit: a 400-step accumulator loop went from 6.53s and 1479MB to 2.53s and 227MB, linear.
    A table whose memo goes unread is also revoked now, per functor.
  • collapse returns a plain expression of its ordered results, matching current Hyperon. This is the
    behaviour change to know about: an empty collapse is () rather than (,).

Documentation is executed, not asserted

The README, the two website pages, and the current section of RELEASE_NOTES.md are run by tests. Every
MeTTa block executes in document order, every printed count and shrunk value is matched against the real
result, and every gen-/expect-/fuzz-/reach- name mentioned must exist as a whole token in the
library. That check earned its place: it caught state-machine fields named from memory, gen-grammar given
an inline grammar instead of a declared one, a fuzz-label that does not exist, a shrinking example that
shrank nothing, and six of twelve wrong defaults in the reference table.

Verification

pnpm install --frozen-lockfile, pnpm build, pnpm lint, pnpm format:check, pnpm typecheck all
clean, and all 169 test files pass, run in four batches because the whole suite at once exhausts memory on
the machine used. The packed tarballs were installed into a clean project and exercised under both scopes,
@mettascript/fuzz and @metta-ts/fuzz.

Benchmarks were run but the machine was loaded at the time, so those numbers are noise-dominated and are
not offered as a clean comparison. The only change to packages/core/src after the last quiet A/B is a
test file.

MesTTo added 30 commits July 29, 2026 16:17
The fuzz effect table classified embedded ops when it was introduced, and
log-enabled? arrived beside it on the reconciled base reading world.logLevel.
The Pure fallthrough was behavior-neutral for the sandbox, which admits both
Pure and World, but a world read must not be labeled referentially
transparent, or a future consumer of the effect table could cache it across
a pragma! log-level change.
A loop that only conses onto its state re-presents the same immutable
subterms to isNormalForm and keyWellFormed on every step, and both
re-walked the whole term each time: a 16k-step cons loop spent 28.6s
in those walks. Atoms are immutable and instantiate shares unchanged
subterms, so both verdicts are cacheable by node identity, the same
lineage as exprVarsCache.

isNormalForm entries carry the visible rule tables they were computed
against: the env identity, its groundedEpoch (bumped on grounded
registration), the world's selfRuleVersion, and the append-only
static-removal log's identity. A node verified true covers its whole
subtree; a false verdict lands on the deciding node and the queried
root. keyWellFormed is purely structural, so its entries never expire.

Core suite 1113 tests and the 23-file Hyperon oracle pass unchanged.
scopeVars instantiated every pending frame just to read variable names, so a
frame embedding a large accumulator cost O(state) per metta-thread call. Walk
the raw frame atoms instead and expand each name through the binding with the
memoised fixpoint resolver; ground values contribute nothing, so the walk is
O(live set). Same disease chainLiveVars was cured of in fd574b8.
isInertData bailed on any defined head, so quote-protected results and
signature-only constructors (declared, type-checked, but never reducibly
defined) re-entered interpret-tuple on every read, costing O(n^2) threading
over machine states. Classify a head as reducible only when a visible static
rule can actually rewrite it, admit standard quote immediately, and let
constructor applications pass the same type check the interpreter would run.
Verdicts are cached by node identity with the normalFormCache entry signature
plus the space version, so a rule or space change invalidates them.
containsImpureHead and hasNanGround re-walked whole terms although machine
states rebuild a fresh wrapper around the same large accumulator every step.
Cache the impure-head verdict by node identity keyed per impure-op set, and
memoise every expression a clean NaN walk visits instead of only the queried
root, so each shared subtree is scanned once per process rather than once per
wrapper.
The grammar slice interpreted every representation walk, so deep templates
overflowed the evaluator and wide ones paid interpreter cost per node.
Normalization, template plans, requirement set algebra, eligibility,
productivity, and decision-leaf extraction are now single grounded kernel
calls over raw template syntax, and generation itself runs on a kernel
machine that replicates the MeTTa specification machine transition for
transition. User Field callbacks and custom generators are served through
a MeTTa trampoline as suspension effects, so fuzzing policy and callbacks
stay ordinary MeTTa relations.

The specification machine and the productivity fixed point remain callable
under -reference names, and a differential test drives default and
reference against identical Random, Edge, Replay, ShrinkReplay, and
Exhaustive drivers; it caught a frozen _fuzz-two-children construction in
the reference production arm, fixed here by let-binding. Regression tests
pin deep and wide templates, reference chains, opaque marker payloads,
Field freeze-once semantics, replay mutation rejection, many-seed identity,
type schemas, and wall-clock scaling gates. A freshness test keeps the
generated module in lockstep with the MeTTa sources on the test path, the
dead _fuzz-grammar-requirement-plan op and stale declarations are removed,
and the shared template walkers are factored (scanTemplateLeaves,
rewriteTemplateLeaves, parseProductionsTargetArgs).
The prefix-product exhaustive driver was unsound for dependent generators:
it forked every integer decision through superpose and sized the domain as
a product of choice counts, so (gen-bind (gen-bool) dependent) claimed four
decision trees where only three exist. The driver is now a deterministic
depth-first cursor, (ExhaustiveCursor choices cursor frames): each integer
decision reads its planned offset or defaults to zero and records an
(ExhaustiveFrame offset count); after a terminal outcome the odometer
increments the rightmost frame with another branch and truncates the
suffix, so dependent suffixes are reconstructed run by run. The kernel
grammar machine mirrors the same transition and loses its fork machinery;
the machine-vs-reference differential now drives resumed cursors through
both machines.

fuzz-enumerate walks a generator's whole domain at one size and reports
(FuzzEnumeration (DomainCount n) (Enumerated m) (GenerationDiscards g)
(Samples ...)), truncating at its attempt limit. fuzz-check-exhaustive
makes the stronger claim behind exit-code semantics: every accepted tree
at MaxSize is replayed and checked, a counterexample goes through the
standard failure pipeline with (Exhaustive (Choices ...)) replay
coordinates, the MaxEnumerated cap and property discards give explicit
FuzzGaveUp results, an empty accepted domain is FuzzInvalid, and coverage
requirements are judged against the exact domain.

Weights now bias only the Random ticket draw: gen-frequency records its
decision in entry-index space like grammar production choice, so
exhaustive enumeration visits each entry once and replayed trees are
weight-independent.
Grounded operations received state handles pre-resolved to their cell
contents, so a handle flowing through collapse, cons-atom, or a list
operation silently became its value and get-state on the result failed.
Hyperon passes handles through opaquely (verified live on 0.2.10: a
handle survives collapse/superpose/cons round trips), so the grounded
argument preparation now substitutes tokens only. The two comparator
families are the exception: == and the assert result comparators judge
states by VALUE in Hyperon ((== (new-state 1) (new-state 1)) is True),
so stateValueCompareOps keeps resolving for exactly those heads while
space reads and match patterns resolve as before (the live-state read
feature). The grounded-exec head path drops resolution the same way.
A tail transfer in the reduce trampoline reused the caller's logical
depth and consumed nothing, so a runaway tail cycle (mutual recursion,
a phase flip, a compiled-operator spin) ran forever while every
legitimate deep tail loop depended on the native stack as its accident
of a bound. Each transfer now debits one unit of fuel, MOPS's
per-transition cost, and exhaustion cuts with the standard
(Error <call> StackOverflow) atom, effects kept, as Hyperon's error
model keeps prior add-atoms. The lone-catch-all carve-out is gone with
its reason: it existed only because the loop deliberately consumed no
fuel.

Transfers also continue through chain-internal steps that carry
app-local variables (a let pattern flowing through its prelude unify
body). The entry step must still be var-free, and finalPair instantiates
every result, so a bound variable bakes its value into the transferred
atom and only genuinely unbound ones stay free; without this every
let-wrapped tail loop fell out of the trampoline at the queryVars guard
and nested one evaluator frame per step. Deep interpreted let-recursion
((build 200000) with an add-atom binding) now completes flat instead of
exhausting memory.

Resource cuts now surface on the trace bus at the public entry
(restrictPublicLimitBindings): cuts arrive from several layers - the
trampoline's fuel cut, the plan machine's exhaustion, a compiled runtime
cut - and the query-level event is what the native-overflow catch
always reported.
Logical depth misses tail transfers (they reuse their level), so a long
chain of wrapped tail steps nested one native generator quartet per
step and a few thousand of them exhausted the host stack; the machine
fuzz checks died at six runs on exactly that. EvaluationDepth now
counts native frame descents separately, and mettaEvalG hands a branch
to the heap continuation driver once the count crosses
NATIVE_EVALUATION_NESTING_LIMIT, so deep nesting runs on the heap and
the guarded multi-rule count-down completes in every mode.

Open atoms (unbound variables left after instantiation) stay exempt:
an open self-expansion mints fresh variables at every level at constant
logical depth (compile-symbolic's (Greater $x $y) regress), so the
heap driver would branch on it until memory dies, while native
recursion cuts it fast and the top-level catch reports the observable
(Error <query> StackOverflow) at counter zero, byte-identical to the
prior behavior.
A tail call in a compiled imperative body invoked the callee's run
directly, so a deep tail-recursive impure chain grew one JS frame per
step and overflowed the host stack at a few tens of thousands of
iterations, while the interpreter's flat tail semantics now complete
the same chain: a genuine compiled-versus-interpreted divergence, in
the slow direction. Hyperon completes this shape (verified live:
build/add-atom recursion returns done plus every item) and PeTTa runs
it flat through last-call optimization.

A tail call now returns an ImpTail transfer sentinel instead of
invoking, and impDrive at the holder entry loops on sentinels: one JS
frame for the whole chain, matespace-class saturation walks included.
Each hop debits one unit of fuel (threaded through CompiledImpureOps
from the evaluator boundary), so a runaway impure cycle cuts with
(Error <call> StackOverflow) instead of spinning; compiled
(build 1000000) completes in under a second. Non-tail recursion keeps
its native-overflow contract. The case-match node defers a tail
sentinel only from the last solution with no earlier survivor, where
the branch result is the case's result verbatim; every other position
must know the branch's value for Empty-pruning and the single-survivor
check, so it drives the sentinel in place. The deep compile-impure
regression guard is inverted to pin completion in both modes - flat
frames are what make completing it safe - plus a fuel-cut guard for
the runaway cycle.
A misplaced closing paren can keep a fragment globally balanced while
folding sibling switch arms into one arm; switch-internal then reduces
the whole call to Empty silently, which cost a debugging session on a
five-item machine draw-step arm. The generator now parses every
fragment (string- and comment-aware) and rejects any switch or
switch-minimal arm that is not exactly (pattern template) before
emitting the module, in both generate and --check modes.
_fuzz-call-one handed collapse-bind to the validator as an evaluated
argument, and a %Undefined%-typed argument branches first: with a
two-rule relation both engines (Hyperon 0.2.10 verified) reduce the
call nondeterministically before collapse-bind can collect, so the
cardinality check saw parallel singleton results and a
nondeterministic user relation slipped through as two passing runs
instead of being rejected. The collapse-bind now sits inside a
function/chain context with a metta interpretation, the prelude's own
case idiom, so the call reaches it raw and every branch lands in one
pair list.

The multiple-results error also reports plain values again
((Results (1 2))) instead of leaking internal (value bindings) pairs.
_fuzz-drive-custom-results kept a superpose arm for the exhaustive
mode, a leftover from the prefix-product driver that forked custom
generators through nondeterminism. Every driver mode is deterministic
now (the exhaustive cursor included), so the validator accepts exactly
one DriveCustom result in every mode and the special case is gone.
A custom generator's own ShrinkChoices candidates were appended after
the built-in child descent, so a structural candidate that removes a
whole subtree (a machine command chunk) was only reached once every
child pass had run and the search had already committed to smaller
edits. Root candidates come first at both levels now: the custom
relation's validated choices, then the generic child descent, which is
the order the shrink search needs to reach the coarse edits while they
still apply.
MesTTo added 19 commits July 30, 2026 15:42
The shrink search accepted any candidate that reproduced the failure,
so a candidate could re-inflate an accepted counterexample: the lenient
shrink replay fills missing draws from each decision's origin, and a
longer run that still fails is still a reproduction. A machine
counterexample shrunk to three increments went back up to six that way.

Two guards, both stated in terms of the published order
(mettascript-shrink-v1 = shortlex over the flattened decision leaves,
fewer leaves first, then lexicographically smaller origin distances).
The runner accepts a reproducing candidate only when its canonical tree
is strictly smaller under that order, which no candidate source (a
built-in pass or a custom ShrinkChoices relation) can bypass. And the
int candidate ladder offers a bound only when the bound sits strictly
closer to the origin than the current value: the far-side bound was an
anti-shrink, and a bound at the same distance is a lateral move the
order could never accept anyway.
A machine is declared as ordinary MeTTa data, (FuzzMachine Name
(InitialModel ..) (InitializeReal ..) (CommandGenerator ..)
(Precondition ..) (Execute ..) (NextModel ..) (Postcondition ..)
(Invariant ..) (Cleanup ..)), and reaches the runner through two
ordinary pieces: (gen-machine Name) generates command sequences and
fuzz-machine-run is an arity-one property that executes them. So
machines inherit fuzz-check, fuzz-check-exhaustive, replay, and
shrinking with no special cases in the runner.

Generation walks the abstract model only. Each step asks the user
generator for a command generator, draws from it, and retries up to
sixteen times until the precondition holds, recording every attempt in
a MachineCommand decision node so replay repeats the rejected draws;
NextModel then threads the model forward. Since NextModel never sees
the real system, the model cannot absorb the behavior it is meant to
predict. Execution checks the invariant before the first command and
after every model update, rechecks each precondition (a violation
discards rather than fails, since the model may have drifted),
judges the postcondition against the PRE-command model, and always runs
cleanup - a cleanup error surfaces only when the run itself passed, so
it can never mask a counterexample.

Shrinking removes command chunks largest-first through a custom
ShrinkChoices relation, then the generic child descent shrinks
individual commands; every candidate replays from the initial model, so
a chunk whose suffix no longer satisfies its preconditions is rejected.
fuzz-reachable explores a model transition relation as deterministic
level-order breadth-first search, so the first witness it finds is
shortest in transition count. The command enumerator returns
(FiniteCommands ...) or (IncompleteCommands reason); a transition may
return several next states and the whole ordered bag becomes outgoing
edges, with each branch's index recorded so a witness names exactly one
branch through a nondeterministic step.

The outcomes are deliberately distinct. FuzzReachable carries a witness
replayed from the initial model first: every step re-enumerates the
commands, requires the recorded command at its recorded index, takes the
recorded branch without deduplication, and the final state must satisfy
the target. A mismatch means the model is not a function of its state,
so the witness is not evidence and the result is a cutoff, never a
reachable claim. FuzzUnreachableWithinDepth says only that no target
occurred at or below the depth completed. FuzzReachabilityExhausted
requires an empty frontier with every enumeration complete and no limit
fired, so it means unreachable in the declared finite model and nothing
about an external system. Every limit, incomplete enumeration, relation
failure, or replay mismatch stays a FuzzReachabilityCutoff.

Ordering is part of the contract: a transition branch is counted before
any deduplication, the target is judged on a produced state before
MaxStates can cut, and the initial state is checked before expansion.

Identity is exact structural equality by default, through _fuzz-atom-key
whose key is a length-prefixed serialization rather than a hash, so key
equality IS state identity and distinct states cannot collide.
(StateIdentity Alpha) merges alpha-variant states and is refused unless
the user declares (FuzzReachEquivariant Name), since equivariance is a
claim about the model. A state whose grounded payload has no
serialization is an explicit UnkeyableState result, never a silently
missed state.

Every loop is a flat accumulator, which a 400-state chain test pins: the
frontier, command, branch, and replay walks each iterate once per state.
The same scaling gate caught two existing recurse-then-cons helpers,
_fuzz-pair-values and the shrink order's _fuzz-leaf-distances, which
failed between 200 and 600 elements and are now flat as well.
analyzeTableWorth admits a pure recursive component to automatic memo
tabling when some rule body contains two or more calls into that
component, the fib-style overlap case. The count was textual, so the
prelude's own let* rule looked doubly recursive: its second mention of
let* is (Error (let* $pairs $template) bad-let-star), a diagnostic
payload rather than a call. let* was therefore tabled, and since it
carries whole templates and accumulators its keys were the largest in
the system while never hitting: a 200-iteration accumulator loop spent
3.5 million key tokens on let* alone, which made every MeTTa loop that
threads an accumulator through let* quadratic in time with unbounded
memory growth.

functorCallCount now skips positions that can never become a redex, and
the predicate covers only Error: its parameters are declared Atom, it
has no equation, and no evaluator instruction hands an Error argument
back for evaluation (verified directly - the payload of (Error (boom) r)
stays unreduced through let, if-error, and return-on-error). Control
constructs are deliberately NOT covered even where a parameter is
declared unevaluated, because let rewrites to unify and unify returns
the chosen branch for the caller to evaluate; a broader rule keyed on
the declared type, or on the head simply lacking equations, wrongly
excluded unify's branches and cost the read fixture its space-read
table. Over-counting only risks tabling something, which is the prior
behaviour, so the predicate stays narrow.

Measured on a 400-step accumulator loop: 6.53s and 1479MB before, 2.53s
and 227MB after, with the curve now linear and flat, matching the
tabling-disabled baseline. Tabling still pays where it should (tabled
fib remains fast) and the standing byte-identical differential over the
adversarial corpus and 300 generated programs is unchanged, which is
what guarantees admission cannot alter results.
gen-list expands its drawn length into that many copies of the element
generator, and _fuzz-repeat-generator built the list by recursing then
consing, so it cost an evaluator frame per element and a thousand-element
list was impossible. The loop is now a flat accumulator; every element is
the same generator, so accumulation order is not observable and no final
reverse is needed.

With this and the let* tabling admission fix, a thousand-element list
generates in 1.19s using 205MB at the default depth and step budgets,
where 300 elements previously failed while allocating 1.8GB. The new
generator test pins that at defaults so neither cause can come back.
Every automatic-tabling decision rested on a static guess: analyzeTableWorth
admits a functor whose body branches into its own strongly-connected
component at least twice. The guess is cheap and usually right, but a wrong
one is expensive, and the preceding commit is the proof - a single
diagnostic mention of let* inside an Error payload was enough to memoize
the busiest control construct in the language with keys that could never
hit.

Admission now also watches the outcome. TableSpace records inserts and
reads per functor, and once a functor has stored 256 entries without a
single read it stops being tabled for the rest of the run. A table is a
pure memo, so refusing one can only cost time and never change a result,
which is what makes measuring rather than proving safe here; the standing
byte-identical differential against untabled evaluation is unchanged. The
key carries its own functor because a chain of keys remembered together
can span several. Statistics are shared across the key domains, so a
functor that pays in one keeps its table in all of them - the conservative
direction.

The threshold sits far above any real warm-up and far below the waste it
prevents. It is also aligned with the admission criterion it backs up:
branching into your own component is exactly what produces repeated
subcalls, so a memo that pays starts hitting within a few entries, while
one that stores hundreds of distinct keys with no read is a functor whose
branching was illusory or whose arguments are never repeated.

Both admission paths are gated. Ground calls go through
groundTableVersionIfAdmissible; non-ground moded calls are admitted by a
separate block in the reduce trampoline, and gating only the first did
nothing at all for let*, which is non-ground.

Measured on a 400-step accumulator loop with the analysis fix reverted, so
the mechanism is doing the work on its own: 6.53s and 1479MB before, 2.37s
and 271MB after, about 95% of the win recovered without knowing the bug
exists. On the corpus nothing regresses and the tabling-heavy cases
improve (peano 0.86x, matespacefast 0.96x).
A run returns one result atom carrying everything a caller needs, and a
host that wants to render, persist, or exit on it should not re-parse that
atom by string matching. decodeFuzzOutcome turns each public outcome -
passed, failed, gave-up, exhaustively verified, invalid, and the four
reachability answers - into a discriminated value with the original atom
kept alongside, so JSON output and artifacts still carry the full result.

Strict means an unrecognized shape decodes as undecodable with a reason,
never a partially filled success: tolerating a shape change would let a
host report a pass for a result it did not understand, which is the one
failure mode worth designing against. The tests feed the library's own
output back through the decoder rather than hand-written imitations, so
drift on the MeTTa side fails here.

Exit codes are part of the contract: 0 for a definitive pass, 1 for a
property failure, 2 for invalid input or data that could not be trusted,
3 for an incomplete run. Across a suite the most severe wins, and the
precedence is deliberate - invalid outranks a failure because the run
could not be trusted to execute, and a failure outranks incomplete
because a found counterexample is the more actionable finding.

Reachability answers are graded by completeness rather than by whether
finding the target is good news, which the tool cannot know: a replayed
witness and a finite exhaustion are definitive, while a depth answer and
any cutoff are explicitly bounded and report incomplete.
A file now declares its tests as ordinary facts instead of through a
second host-side test DSL:

  (FuzzTest reverse-involution (gen-list (gen-int -100 100) 0 40)
            reverse-involution (fuzz-config (Runs 200)))

fuzz-run-suite finds every FuzzTest fact and runs it through the ordinary
fuzz-check, so a declaration is exactly what a hand-written query would
have been and inherits replay, shrinking, and exhaustive enumeration with
no special cases. The generator stays syntax in the declaration and is
evaluated once per run when fuzz-check receives it. fuzz-suite-tests lists
declarations without running them, and each result is paired with its test
id so a host reports per-test without re-deriving identity from the result
atom.

Command-line overrides merge by REPLACEMENT. A config carrying the same
option twice is an explicit DuplicateOption error, so appending would break
every run that overrides an option the test already set; the test's other
choices survive.

Two evaluation-order traps, both worth naming because they are silent. The
loop trio is deliberately left undeclared: an Atom parameter freezes its
argument, so declaring the loop would freeze the step call it is handed,
run exactly one iteration, and return that unevaluated call as the result.
And the merged config is inspected with unify rather than switch, because
switch evaluates its scrutinee: a config that normalizes into a validation
error would match an error arm that then reports the pre-evaluation syntax
instead of the error itself.
MaxDepth used to bound the levels the search expanded, so it generated and
target-checked states one level deeper: a search asked for MaxDepth 2 could
report a three-command witness, and a depth answer named a smaller depth than
the one it had actually verified.

MaxDepth now bounds the depth of every state considered, so a witness never
exceeds it and FuzzUnreachableWithinDepth means exactly "every state at or
below this depth was generated and judged". The boundary level is still
enumerated, because a state with no command ends the model while a state with
commands continues past the bound, and that is what separates exhaustion from a
depth answer. A boundary state that does have commands leaves a marker in the
next level instead of being stepped, so no transition is charged and no
out-of-bound state is ever built.
…each

A suite file declares its tests as data, so the CLI reads the file's
declarations, drops its own directives, and evaluates its own query instead.
Dropping them is the point: reading a suite must not execute whatever else the
file would have run. `import!` and `register-module!` are kept, because a
property defined in an imported file is undefined without them.

Reachability searches are now discoverable the same way, through FuzzReachTest,
and both suites take command-line settings as config overrides. The merge is one
head-parameterized routine serving fuzz-config and reach-config, and it names an
option by its head symbol rather than through the fuzz-config whitelist, which
reported every reach option as InvalidOption and so kept the option it was
supposed to replace.

Exit codes: 0 pass or definitive answer, 1 property failure, 2 invalid input or
data, 3 incomplete run. Precedence is worst-first across a run, with invalid
outranking a failure because it means the run could not be trusted to execute.
A witness longer than eight commands is elided with its true length, since the
full command list is in the result atom and --json prints it.
The runner already had the replay phase: it matches (FuzzRegression <property>
<value> <replay>) facts in the space and runs them before generating anything.
A corpus is those facts written down, which `metta fuzz --corpus <dir>` now
reads before a run and records into after one.

Entries go through the same versioned codec MeTTa uses for replay rather than
plain formatting, because a counterexample may have no source syntax: a float is
stored as its two 32-bit words, so NaN, the infinities and negative zero survive
exactly, and a value carrying a live host object is refused outright instead of
being written back as something else. Each entry is one file named for the
digest of its own text, so recording a counterexample that is already stored
writes the name that is already there: deduplication is structural and a write
never has to consider replacing another entry. New files land through a
temporary name in the same directory followed by a rename.

A corpus file that cannot be read stops the run with exit 2. Skipping it would
drop a known failure while still reporting the run as complete. Every top-level
atom in a file must be one well-formed entry, so nothing else in it can travel
into the program alongside the entries.

Results now go to stdout and diagnostics to stderr, so --json prints exactly one
document however much the run has to say about the corpus.
@mettascript/fuzz had no @metta-ts/fuzz shim beside it and sat at 2.5.1 while
every other package was at 2.7.0, which the release bump refuses to run across.
Both are now fixed, and the parity test checks the pairing in the direction that
would have caught it: walking compat/* alone cannot notice a canonical with no
shim, so it now also asserts every publishable package has one and that all of
them carry the same version.

The README's MeTTa examples are executed by a test rather than trusted. Writing
them turned up two things a reader would have hit: the state-machine fields were
named from memory and wrong, and 200 runs report 213 passes because Runs counts
the random cases with edge cases drawn on top. The counts printed in the README
are now read out of the document and checked against a real run, and every
gen-/expect-/fuzz-/reach- name it mentions must appear as a whole token in the
library, which is the check that catches `fuzz-machine` for
`fuzz-check-machine`.
… a run

A tutorial and an API reference for @mettascript/fuzz, wired into the sidebar,
the packages overview, and the CLI page. Both new pages are executed by a test:
every MeTTa block runs in document order, every outcome kind the prose prints has
to be one the run produced, and every count, shrunk value and witness printed is
matched verbatim against the results.

Writing them under that check turned up three things a reader would have hit.
`gen-grammar` takes the name of a declared FuzzGrammar, not an inline grammar
term, so the example errored with MissingGrammar. There is no `fuzz-label`; the
annotations are fuzz-annotate, fuzz-classify, fuzz-collect and fuzz-cover, and
cover takes its percentage first. And the shrinking example shrank nothing,
because its failure came from an edge case that was already minimal: with a
minimum length of one and edge cases off it now finds (4 -5) and reduces it to
(-1) in five attempts, which is what the surrounding prose claims.

The CLI's options are compared with the documentation page in both directions,
since a documented flag that is silently ignored is worse than one that is
missing.
…erty

The typed view of a failure carried only the shrunk value, though the result
carries the original too and the difference between them is the information a
reader wants. Both are decoded now, and a decoded field is the value rather than
the label around it: the details fields and the replay come out as their records,
the way the smallest value already did.

Node, browser, and hyperon each tested that the fuzz kernel's grounded
operations were registered, which is not the same claim as the MeTTa module being
usable. Each now runs a whole fuzz-check through its own entry point.

The example this all exists for is in examples/property-testing.ts and its
suite file, both run by the CLI test, so an example that stops working fails
rather than sitting there. Writing the TypeScript one is what turned up the
missing original value.
Running the example suite under --exhaustive took over three minutes before
reporting EnumerationLimit. Measured as the marginal cost of one more case, a
case here is milliseconds of interpreted MeTTa: about 7ms for a scalar generator,
12ms for a small list, 18ms for a list of forty under the exhaustive cursor. The
default bound of 10000 multiplied straight into that, so a domain that could
never fit was walked for minutes before the run could say so. A thousand keeps it
under half a minute, and a larger domain is still coverable by asking.

The measurement is worth stating precisely: the exhaustive cursor is only 1.2x
the random driver on the same generator, and per-case cost does not depend on the
size of an integer range, so this is the run loop's own cost rather than anything
about enumeration.

Six of the twelve defaults on the reference page were wrong, all of them
plausible. The page's table is now read out of the document and compared with
what the library reports, so a default written from memory fails.
They agree on the scalar kinds and differ on numbers, which is the point: a
replay key has to separate values a rerun must reproduce exactly, so an integer
keeps its kind and a float keys on its bits, while an identity key has to match
core's numeric equality and keys both on the Number projection. Factoring the
agreeing arms together would save four lines and hide the one distinction a
reader has to check, so a duplication report on this pair is expected.
Every package moves together, which the release script refuses to do unless they
start in lockstep. Release notes cover the fuzz library, the CLI subcommands, the
collapse change that was sitting unreleased, and the interpreter work on deep tail
recursion and tabling, with the tabling numbers quoted from the commit that
measured them rather than from memory.

The notes' current section is now executed by the documentation check, the same
way the README and the website pages are. Earlier sections are left alone: their
examples belong to the releases they describe.
The tutorial and the reference were reachable from the sidebar but nothing led to
them: the landing page listed nine things the toolkit does without this one, and
the only place a new reader is told where to go next did not mention it.
It pairs a fuel budget with a wall clock, because a mode that ignored fuel would
run forever and a timeout is how that shows up. The first version paired 200,000
fuel with ten seconds, which passed here and timed out on a shared CI runner that
was also running the differential suite: the slowest of the twelve combinations
crossed the budget and the test read as a fuel bug when the machine was just
busy.

Burning fuel costs time in proportion to it, 3.9s for 200,000 and 1.1s for
50,000 in the slowest mode, with the same StackOverflow outcome at every level.
The budget is now 50,000 fuel against a minute, so only a mode that never
terminates reaches the clock.
@MesTTo
MesTTo force-pushed the feat/metta-fuzz branch from 4ea69b6 to 4f5d93c Compare July 30, 2026 18:30
@MesTTo

MesTTo commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

Landed on main as the squashed release commit 382e2a2 (MeTTaScript 2.8.0), keeping main's one-commit-per-version history. The tree of that commit is byte-identical to this branch's head. Published to npm under both scopes and released as v2.8.0; CI, Publish, and Deploy docs are all green on main. The branch stays for its atomic history.

@MesTTo MesTTo closed this Jul 30, 2026
@MesTTo
MesTTo deleted the feat/metta-fuzz branch July 31, 2026 00:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant