“the model isnt the logic/reasoner.. the ‘cards’ is”

That was the whole brief. This is what it measures.

Build the ruler before the thing it judges

Three benches already existed and not one could report on a reasoning change. werbos/gauntlet’s top level is called “chain” and is single-hop retrieval past a distractor. reason_gold.jsonl’s ten compound rows are all the same shape — one card matches every one of them. And scripts/gauntlet.py, the good one, is saturated at 478/478, and a saturated bench cannot show an improvement.

So: 10 families, 60 probes, every one needing a step whose input is the previous step’s output, gold computed in Python from the same inputs the model gets. Ten of the sixty are unanswerable — the same problem with one datum removed. Without those, a reader that never declines scores 22% and looks merely weak rather than confidently wrong.

Writing the tests found two bugs in the ruler, which is the argument for writing them:

  • _norm compared a Python float gold against a model’s string answer, so 4.0 failed against 4 and 7,570 failed against 7570.0. It would have measured formatting while claiming to measure reasoning.
  • Today is Wednesday. What day is it 7 days from now? printed its own answer in its own question. A full week wraps to the start, so an echoing reader scored it for free.

And a third later, worse in direction: the grader took splitlines()[-1] strictly, so a reader that shows its working and ends “so the final price is 99” scored WRONG. That measures obedience to the output format — and it understates the baseline, which makes every change measured against it look better than it is. A ruler must not flatter the thing it was built to judge.

Thirteen moves

A shape is a card whose content is a move:

when      the situation it applies to
cues      surface markers, so matching is cheap and inspectable
needs     what must be known before it can fire
yields    what it produces
step      the move, stated so a model only has to APPLY it
check     a postcondition — a step failing its own check is rejected, not shipped

needs/yields is the load-bearing pair: it makes chaining a graph search instead of a judgment call. reason() used to ask a model what sub-questions a query implies. A matched shape already states them.

The pipeline, with the model as cursor at every stage it used to own:

user →  screen()      which move, and does the problem supply its inputs
     →  cards         the step, a worked example, a postcondition
     →  model         applies the named move; it never chooses one
     →  run_check()   fails its own check → not shipped, becomes an abstain
     →  user

Three kinds of absence

The traps split the design open. precondition-check caught every trap whose phrasing gave the gap away — “a jug of unknown size” — and missed every trap where a number was simply absent, because there is nothing to match on:

"A tank holds 2 gallons. There are 3.785 litres in a gallon. How many millilitres?"

That reads exactly as complete as the answerable version. No cue list of any size finds that hole. Counting the quantities a problem states against the arity its move requires does.

So absence gets three detectors, one per kind: arity for a missing number, cues for an absence stated in words, and the shape’s own check for data that is present but structurally incomplete — “Ana is faster than Bo. Cy is faster than Dee. Who is fastest?” is missing nothing; the resulting order is partial, and only running the move can see that.

The numbers

Same probes, same output instruction, gold with no model in it:

                     SOLVED (50)   REFUSED (10)   lies   check recall
bare llama3.2:3b       8  (16%)      3  (30%)      48        –
cards + llama3.2:3b   16  (32%)     10 (100%)      13       59%
bare owned 336m        0  ( 0%)      7  (70%)      32        –
cards + owned 336m     1  ( 2%)     10 (100%)       1       97%

The bare 3B declined three traps in ten. Asked ten questions withholding a datum they need, it produced a confident number seven times.

The clearest single result produced no new right answers at all. inclusion-exclusion, one card edited:

before   0 pass   0 abstain   5 wrong
after    0 pass   5 abstain   0 wrong

Its check had been 0 < ans < max(nums), which admits anything under 100 — so it waved through 53, the family’s classic error, forgetting the overlap. The bracket the identity already implies catches all five. Capability on that family is still zero. The card could not make a 3B do inclusion-exclusion, and it could stop it pretending to.

One of the three revised cards wasn’t a check at all. modular-wrap scored 0 pass / 5 abstain: the model computed (0 + 10) mod 7 = 3 and answered 3 instead of Thursday. The check was right — it rejected all five, turning five lies into five abstains — and the card was wrong, because it asked for a name without insisting. A card can be perfectly honest and still not do its job.

A better tongue is a worse witness

talk-336m — the model pinned live — scores clean 1.00 on talk_bench: real words, no babble, no truncation. The only one of thirteen checkpoints to do it, against a 0.80 ship floor. 4,921M tokens of conversation bought exactly what they were spent on.

That is the same fact as its 32 lies. Getting better at producing well-formed English makes a wrong answer well-formed too. And the bench already named the gap — ood sits at a 0.00 floor with the comment “no current checkpoint abstains on nonsense — tracked as DEBT, the number to drive up.” The reasoning gauntlet measures that debt on real questions instead of nonsense.

Cards pay it down without retraining: 32 lies to 1, honesty 40% → 98%, zero right answers killed.

Recall is higher on our model than on the 3B (97% vs 59%), which isn’t a paradox — a 336m wrong answer is malformed in ways a postcondition catches trivially. A better-formed wrong answer is a harder one to reject.

Three things that were wrong for hours

A module the laptop could import and the server could not. compose/shapes.py used a backslash inside an f-string expression. PEP 701 legalised that in 3.12, which the laptop runs; on 3.11, which the container runs and which pyproject promises, it is a hard SyntaxError at import time. It passed 8,157 tests and could not be loaded at all on the server. Nothing in the normal loop catches it: the tests run on the interpreter that accepts it.

The guard for it went wrong first, which is the more useful half. ast.parse(feature_version=(3,11)) looks exactly right and does not work — it gates AST features, not f-string tokenisation, and accepts the precise line that killed the container. A self-test asserting the guard catches its own bug is the only reason this didn’t ship a check that guards nothing and reads like it guards everything. The working version immediately found a second instance nobody introduced that day: gauntlet/benchpage.py has been unimportable on 3.11 for as long as it has existed.

A mute reader reading as a weak one. The owned model first reported “18% capability, 98% honesty”, which looks like a small model that knows its limits. Every pass but one was an unanswerable row it walked past by never committing. A blended pass rate hands a silent reader the trap fraction for free. SOLVED and REFUSED are separate now, and the whole table was recomputed.

A resolver that always resolves to something. reader.talk: reads ~/werbos/brains/current, and that symlink doesn’t exist on the laptop, so it fell through to its documented fallback — and loaded a 1.09M model while a write-up called it the 336M. The bench printed the spec, never the resolved model, so nothing in its own output could contradict the label. It prints the winner now: talk: -> talk-336m · 336.39M params.

And effort=high finally means something

It has equalled effort=mid since the dial shipped, with effort.py’s own docstring promising it would deepen “as decomposition wires into the pipeline.” Typed needs supply that, so the note is paid: LOW 0, MID 1 — today’s default, unchanged — HIGH 3. Bounded, because a runaway fan-out is not thinking.

The AHA

Yesterday the harness deleted work that didn’t survive measurement. Today it did something harder:

The library cannot make a model reason. It can stop it lying.

Capability moved 0/50 → 1/50 on our own weights, which is noise. Lies moved 32 → 1, which is not. Those are different axes, and only one of them is bought with parameters.

The honest caveat sits in the same paragraph: the holdout was spent. It read 45% routed frozen, then cues were tuned toward its misses, and shape_coverage.py prints that warning in its own output rather than leaving it in a commit message nobody opens. Ten families of arithmetic with executable postconditions is exactly the class the 30B fight is scored on — and it is not evidence about open-ended reasoning, where a cheap postcondition may not exist at all.