Skip to content

thinking-probes: read the reasoning field under both names#13

Open
Defilan wants to merge 2 commits into
TheTom:mainfrom
Defilan:fix-runner-server-compat
Open

thinking-probes: read the reasoning field under both names#13
Defilan wants to merge 2 commits into
TheTom:mainfrom
Defilan:fix-runner-server-compat

Conversation

@Defilan

@Defilan Defilan commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #8. @Blackwellboy's fallback landed in spine-probes/run_probes.py, but the two thinking probes still read reasoning_content only.

That is the worse place to be missing it, because those two tools exist specifically to measure whether a model reasons:

  • thinking_ab.py would classify every arm as NO_REASONING on a vLLM lane, reporting that the model never thinks
  • persona_ab.py would show 0 reasoning in all four cells, reporting the persona gate as perfectly effective including in the control

Both are fabricated results, and both are the same class of error as the enable_thinking claim these tools were written to correct: concluding from a field instead of measuring the thing.

Verified the parse path both ways:

vLLM (reasoning)                 -> REASONED_PARSED
llama.cpp (reasoning_content)    -> REASONED_PARSED
neither field                    -> NO_REASONING

README gains the field-name note plus one guard rail: NO_REASONING across every arm including true means check the field name before believing it, since a stack exposing thinking under a third name would still read as silent.

All three scripts still run and exit clean. No em or en dashes in the changed files.

Assisted-by: Claude Code (Opus), reviewed by me

@Blackwellboy's offlabel#8 fix landed in spine-probes/run_probes.py but not in
the thinking probes, which is the worse place to be missing it: these two tools
exist specifically to measure whether a model reasons.

llama.cpp populates reasoning_content; vLLM exposes reasoning. Reading only the
first makes every arm on a vLLM lane classify as NO_REASONING, so thinking_ab
would report a model as never thinking and persona_ab would report the persona
gate as perfectly effective in all four cells. Both are fabricated results, and
they are the same class of error as the enable_thinking claim these tools were
written to correct: concluding from a field rather than measuring the thing.

README gains the field-name note and one guard rail: NO_REASONING across every
arm including true means check the field name before believing it, since a
stack exposing thinking under a third name still reads as silent here.

Signed-off-by: Christopher Maher <chris@mahercode.io>
@Blackwellboy

Copy link
Copy Markdown
Contributor

Good catch on the two probes, and you are right that they were the worse place to be missing it. A tool whose whole job is to detect reasoning reporting NO_REASONING on every arm is a fabricated result that looks like a finding, which is the same failure mode twice over.

One mechanical thing, not about the change itself: the diff carries two compiled artifacts.

scripts/thinking-probes/__pycache__/persona_ab.cpython-314.pyc
scripts/thinking-probes/__pycache__/thinking_ab.cpython-314.pyc

I did exactly this on #9 and caught it just before it merged. The repo root .gitignore currently only holds the confidentiality entries, so there is nothing stopping __pycache__ from landing. git rm -r --cached scripts/thinking-probes/__pycache__ clears it, and a __pycache__/ line in .gitignore would stop it happening to any of us again.

The guard rail in the README is the part I like most here. NO_REASONING across every arm including true is exactly the signature that should make someone check the field name rather than believe the number, and a stack exposing thinking under a third name would still read as silent.

@Blackwellboy

Copy link
Copy Markdown
Contributor

Reviewed the diff properly rather than just agreeing with the premise. Three things checked, two clean, one suggestion.

The fallback matches what merged into run_probes.py exactly, same order and same shape:

reasoning = (ch["message"].get("reasoning_content")
             or ch["message"].get("reasoning") or "")

reasoning_content first, reasoning second, empty string last. So a stack that populates both cannot change verdicts depending on which tool you ran, which is the property you want across a probe suite.

The parse paths you describe are the ones the code implements. verdict() is the three-branch function your table documents, and the fallback feeds its first branch, so both field names now reach REASONED_PARSED and the <think> marker path is untouched as the REASONED_UNPARSED fallback. Nothing else changed: README plus eight lines across the two probes, no behavioural edits smuggled in alongside.

On the guard rail, one suggestion. The README note is the right instinct and I would go one step further, because this failure mode is silent by construction and a documentation guard rail only works on someone who reads the documentation at the moment they need it. The condition you describe is machine-checkable: if every arm including true comes back NO_REASONING, that is either a genuinely non-reasoning model or a field-name miss, and the tool knows which arms it ran. A single line at summary time saying so would make it self-catching:

all arms NO_REASONING including true: check the field name before believing this

That turns your guard rail from advice into a positive control, which matters more here than usual, because the thing being measured is absence, and absence is exactly what a wrong key also produces.

On the third-field-name case specifically, your wording covers it correctly. Worth noting the partial protection people may not realise they have: a third-name stack that still leaves <think> in content gets caught by REASONED_UNPARSED. The genuinely silent case is a third field name and clean parsing, which is the one your note is for.

I am updating trap 1 in our own template-traps registry to list these two probes as affected surfaces alongside the spine runner. That entry previously read as three occurrences of the field-name problem, and with these it is five, across three different tools. Worth recording because the pattern is now clearly "any tool that reads a reasoning field" rather than "a bug that happened to some scripts".

Separate mechanical note in my earlier comment about the two .pyc files in the diff.

…elf-checking

Two review items from @Blackwellboy on TheTom#13.

Mechanical: the branch carried two .pyc files under
scripts/thinking-probes/__pycache__. Untracked them, and added
__pycache__/ and *.pyc to .gitignore, which previously held only the
confidentiality entries and so had nothing stopping this. Verified by
regenerating the cache with py_compile and confirming git now ignores it.

Substantive: the README told the reader to check the field name when every
arm comes back NO_REASONING, but a guard rail only works on someone reading
it at the moment they need it. The condition is machine-checkable, so both
sampling tools now print it at summary time. That turns advice into a
positive control, which matters here because the thing being measured is
absence and a wrong key produces absence too.

The check stays quiet whenever it would be guessing:

- nothing scored at all (every request errored): no conclusion available
- the thinking-enabled arm errored rather than returning nothing: the arm
  that would carry the signal was never measured
- any REASONED_PARSED or REASONED_UNPARSED anywhere: the reader works

The two tools key on different things and are not copies of each other.
thinking_ab.py derives the enabled arm from ARMS rather than hardcoding
"true", so it stays correct if the arms change. persona_ab.py has no true
arm at all: its false cells are the structural control where zero is the
expected result, so it keys on the absent cells instead.

Signed-off-by: Christopher Maher <chris@mahercode.io>
@Defilan

Defilan commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Both fixed in 923119f, and thanks for reviewing the diff rather than the premise. Checking that the fallback matches run_probes.py in order and shape is the check that actually matters here, since the property worth having is that a stack populating both fields cannot give different verdicts depending on which tool you ran.

The .pyc files. Gone, and .gitignore now carries __pycache__/ and *.pyc. It previously held only the confidentiality entries, so as you say there was nothing stopping it. I verified the fix rather than assuming it: re-ran py_compile to regenerate the cache, then confirmed git check-ignore -v attributes it to the new rule and the net PR diff is down to four text files. Slightly galling given that a stray artifact is exactly the class of thing these probes exist to catch.

The guard rail. Took it, and you were right that documentation is the weaker form. Both tools now print at summary time:

  WARNING: every arm returned NO_REASONING, including true where thinking is
  explicitly enabled. That is either a genuinely non-reasoning model or a
  reasoning field this tool does not read.

Two things I did deliberately that are worth flagging, because a positive control that fires wrongly is worse than none.

It stays quiet whenever it would be guessing. Specifically when the thinking-enabled arm errored rather than returned nothing: that is the case where the arm carrying the signal was never measured, and blaming a field name would be inventing a diagnosis. Same when every request failed. I wrote ten cases covering both directions and confirmed the guard fires on the real signature and stays silent on each of the others, including against the committed blob rather than my working copy.

The two tools are not copies. thinking_ab.py derives the enabled arm from ARMS rather than hardcoding "true", so it stays correct if the arms change. persona_ab.py has no true arm at all, and there its false cells are the structural control where zero is the expected result, so keying on "every arm including true" would have been silently wrong there. It keys on the absent cells instead. That distinction is in the README and in both docstrings, since it is the sort of thing a later copy-paste would flatten.

On your registry entry: five occurrences across three tools sounds right, and "any tool that reads a reasoning field" is the correct generalization. I would add that the class is narrower than it looks in one useful way. A third-name stack that still leaves <think> in content gets caught by REASONED_UNPARSED. The genuinely silent case needs a third field name and clean server-side parsing, which is what the note is for and why the machine check earns its place.

Separately, your #16 lands on something I need to check on my own stack rather than assume transfers, so I have replied there.

@Blackwellboy

Copy link
Copy Markdown
Contributor

Nice work, and the part I'd highlight for anyone reading later is the guard staying silent when the enabled arm errored. A positive control that fires on a case it cannot diagnose is worse than no control, and ten cases run against the committed blob rather than the working copy is the right bar. The ARMS derivation versus the absent-cell keying is the sort of asymmetry that gets flattened by a well-meaning refactor, so having it in both docstrings is worth more than it looks.

Taking your narrowing on the registry entry: a third field name plus clean server-side parsing is the genuinely silent case, and REASONED_UNPARSED catches the rest. That is more useful to someone diagnosing than my broader version, so the entry is being reworded to say it that way. The registry is moving to its own repo shortly since it is not Laguna specific, and contributions are open when it lands.

Looking forward to your #16 result on your stack. That one needs a second stack more than most, since the whole mechanism is template-rendering behavior and yours renders differently.

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.

2 participants