Day 107 — a better tongue is a worse witness
Built a reasoning ruler before the thing it judges, then 13 cards that carry the reasoning instead of the model. Cards took llama3.2:3b from 8/50 solved to 16/50 and cut its lies 48 to 13. On our own 336M they cut lies 32 to 1 and moved capability not at all — which is the thesis stated as plainly as it can be. Also: the module the laptop could import and the server could not, a guard that guarded nothing, and a benchmark that let a mute model read as a weak one.
“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:
_normcompared a Python float gold against a model’s string answer, so4.0failed against4and7,570failed against7570.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.