feat(engine): replace the ablation scoring mode with a checked, digest-bound one - #1356
Open
monotophic wants to merge 14 commits into
Open
monotophic wants to merge 14 commits into
monotophic wants to merge 14 commits into
Conversation
The evidence tooling reads the engine's own stdout. Two of its lines are typed records -- the "== GLM C engine" banner and the "loaded in ..." line that follows it -- and every consumer that wants a field out of them was about to reimplement the same grammar. This adds one module that owns the exact text, the field ranges each number may take, and the errors a malformed line produces, so the consumers agree by construction. It also owns the canonical form of an ablation manifest: the engine reads a manifest a line at a time, drops the terminator and one carriage return before it, and digests the record followed by a single newline. canonical_manifest_bytes() is that rule written once for the Python side, so a checker and the engine cannot drift into hashing different bytes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
check_ablate_evidence.py re-checks one ablation manifest and its JSONL evidence artifact against an independently supplied config.json, without the engine and without any test binary: the config and manifest field bounds, every record's key set and field types, the header's binding to the config and to the manifest digest, and the truncation, replay and mismatch cases at each record boundary. Three cross-record invariants are enforced because the producer guarantees them for every row it can emit -- nll is never negative, corr agrees with the argmax-equals-gold comparison, and the argmax logit is never below the target's. Top-k order is deliberately not enforced: it is unsorted on the wire by design. The manifest is reduced through the shared canonical form before it is parsed or hashed, so a manifest saved with CRLF endings or without a final newline -- both of which the engine accepts and normalises -- is checked rather than refused, and binds the same digest the engine bound. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
eval_glm.py scored answers from whatever the engine printed and wrote its results only after the whole run finished. Two consequences: a foreign line on that stream was absorbed silently, and an interrupted run left nothing to read. Result rows are now appended per request, so a run that dies mid-task leaves a valid partial file carrying an INCOMPLETE marker and still prints the accuracy table over the rows that landed. Every line of the scoring stream is classified against the engine's documented SCORE-mode output and an unrecognised line is refused by name instead of ignored, and the numeric grammar accepts both the six-decimal form the engine ships today and the older %.17g form. Where the engine build cannot supply the evidence record the harness would bind to, the run is marked UNBOUND by name rather than reported as bound. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
pack_python.py reaches a file two ways: by following imports out from coli, and by spotting scripts that are launched as subprocesses. Only the first kind had its own imports followed, so a module that only a subprocess-launched script imports was left out of the release archive while --check still reported the archive complete. On dev this happens twice: tools/iq3_pack.py, which tools/convert_fp8_to_int4.py imports inside quant_e8(), and tools/engine_evidence.py, which this branch's eval_glm.py imports. Both are now reached. The test suite grows a real-tree assertion as well as fixture cases, because every existing case builds a disposable tree and so could not have caught either omission. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A mode that writes a number into an artifact needs to know why a row was unusable, not only that it was. logprob_target() answers what a sampling loop needs and nothing more. logprob_row_checked() reduces a row and reports the cause in a fixed order: a row with no finite entry at all is reported by its shape, and otherwise a NaN outranks an infinity, because a NaN cannot have come from saturation. logprob_from_row_checked() reads a target out of an already reduced row and propagates that cause; a missing row, a missing logit vector or a negative target is a caller mistake and is reported as invalid rather than as a usable value, and every return path writes the output, so no caller can consume a number that was never computed. The plain path is untouched and the new path is pinned to agree with it on every well-behaved row. evidence_digest.h adds a short self-contained SHA-256 so a mode can name the exact bytes it consumed without making the engine depend on a library it otherwise never needs. It is an aid to reproducibility, not a security boundary. The tests cover the agreement property, each exceptional row shape and the precedence between them, the caller mistakes, and the published SHA-256 vectors at the block boundary and both padding branches. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
ABLATE_SCORE=<manifest> replaces the decode with a teacher-forced sweep. That mode already existed, but it could not produce evidence anyone could check: it accepted whatever a permissive whitespace scan could make of a line, skipped lines it could not parse and carried on, silently truncated an over-long cell list, and wrote records naming neither the config nor the manifest they came from. A partial run looked exactly like a complete one and exited 0 after writing nothing but a header. This replaces the mode's implementation, and the change is visible to anyone parsing its output: the artifact's schema goes from coli-ablate/1 to coli-ablate/2 and the mode's exit status is now its own result rather than a constant 0. The manifest is a defined format with an exact field count and single-space separators, so the same content always produces the same digest; every field is bounded against the loaded config before anything runs; anything outside that is refused as a whole, naming the record at fault, including a duplicate item id. One item may declare at most a million tokens, so a corrupt manifest cannot ask the loader for an arbitrary allocation. A CRLF terminator and a missing final newline are accepted and normalised, since a host editor produces them without meaning to, and all three framings of the same content bind the same digest. The artifact gains the digests of the config and the manifest, the item and target counts it expects, and a terminal record carrying the counts actually completed, so a truncated file is detectable. Positions whose logits are not finite are refused by name, the top-k list holds real vocabulary entries instead of padding a fixed width with sentinels, and the output path is created exclusively, never truncated. Every refusal says why on stderr before the mode returns non-zero. The reported numbers are unchanged: the negative log-likelihood and the partition function are computed exactly as before, in double, and are printed at full precision now, so a reader comparing old and new files sees more digits of the same value -- a committed test pins that on a row where a single-precision intermediate would differ. load_cfg records the digest of the config.json bytes it read when, and only when, the ablation mode is what is being asked for; callers that do not need it pass no buffer. The mode still runs only when its environment variable is set, and no other path reaches it: tests/test_ablate_mode_gate.py holds that property mechanically and runs the real producer against tools/check_ablate_evidence.py, saying so loudly when it cannot. tests/test_ablate_mode.c covers the loader, the writer, the accepted framings, the named refusals and the dispatch contract with no model and no weights. docs/ENVIRONMENT.md describes the mode as it now behaves. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
test_makefile_deps.py (upstream JustVugg#1284, arrived via the dev rebase) enforces that every engine rule's prerequisite list covers the headers its source includes. colibri.c has included evidence_digest.h since this branch added the logit-row status layer; the colibri$(EXE) rule's prerequisite list did not.
logprob_from_row_checked() took its per-target subtraction in float
(lo[target]-r->max) before widening to double, matching
logprob_target()'s historical float-scale subtraction on purpose. On a
row whose logits span several orders of magnitude, that float
subtraction rounds away up to an ulp of the row's maximum before the
value is ever seen in double -- about 2e-6 on a real vocabulary's logit
scale, and as much as several tenths on a more widely spread row
(measured: 0.25 on a synthetic row with a max of 1e7 and one target's
logit at 0.25).
Promote the logit to double before subtracting the row's own double
logZ (max+logse) instead, matching the arithmetic every other step of
this reduction already uses. logprob_target() is unchanged; it keeps
its own float-scale subtraction, so the two paths can now disagree past
a float's own precision on a widely spread row -- which they always
could in principle, the existing agreement test's rows were simply too
narrow to show it.
No value this codebase writes to disk moves: the ablation evidence
writer (ablate_logit_record) already discards this helper's return
value and computes its reported nll independently, wholly in double,
from the same row (logZ - lo[gold]); that arithmetic is untouched.
One behavioural change is disclosed: a row whose float subtraction
would overflow (e.g. {FLT_MAX, -FLT_MAX}) now returns LOGPROB_FINITE
with a usable value instead of LOGPROB_FINITE_OVERFLOW, since the
double subtraction does not saturate. This cannot occur on real logits
(it needs a spread of about 1e38 within one row) and is pinned by a
test.
Adds a second double computation of the row's max and log-sum-exp in
the test (the same formula, so it catches the float-first rounding this
change removes, not ulp-level error) over ordinary, widely spread,
moderately spread and subnormal rows -- the classified value agrees to a
few units in the last place -- and updates the two existing test
expectations that named the old arithmetic.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
On Windows, os.kill(pid, SIGTERM) is TerminateProcess with the exit code set to the signal number: no Python handler runs and the whole unittest process dies with status 15, which is how the Windows UCRT64 job reported `make: *** [test-python] Error 15` with no test summary. The behaviour the test asserts (a real SIGTERM converted into engine-child cleanup) is a POSIX mechanism; the SIGINT/exception half of the pair covers child cleanup on every platform. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
On Windows, os.kill(pid, SIGTERM) is TerminateProcess with the exit code set to the signal number: no Python handler runs and the whole unittest process dies with status 15, which is how the Windows UCRT64 job reported `make: *** [test-python] Error 15` with no test summary. The behaviour the test asserts (a real SIGTERM converted into engine-child cleanup) is a POSIX mechanism; the SIGINT/exception half of the pair covers child cleanup on every platform. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 0a8bc2a)
… its newline The checker prints its PASS line with print(), so a Windows child hands the test CRLF where POSIX hands LF, and the byte-exact assertion failed on the Windows UCRT64 job (the only failure in a 914-test run). Nothing consumes that line byte-for-byte; the pin is on the content, so the test now folds CRLF to LF before comparing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… its newline The checker prints its PASS line with print(), so a Windows child hands the test CRLF where POSIX hands LF, and the byte-exact assertion failed on the Windows UCRT64 job (the only failure in a 914-test run). Nothing consumes that line byte-for-byte; the pin is on the content, so the test now folds CRLF to LF before comparing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit b8d3c67)
Resolves the conflict JustVugg#1364 created, so this branch does not need a rebase. JustVugg#1364 landed the packer commit from this PR (cherry-picked, authorship intact) plus a second edge it left open: a data file opened next to a reached module. tools/pack_python.py therefore takes dev's version wholesale, which is a strict superset of this branch's -- the only lines unique here were the ones that commit replaced. tests/test_pack_python.py takes dev's version and keeps this branch's engine_evidence assertion as an ADDITIONAL real-tree case rather than a replacement. On dev that assertion could not run, because engine_evidence.py is introduced here; on this branch it pins the edge this branch adds, next to the one that pins the historical bug. 12 tests, all green.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Authored by Claude Opus in Claude Code, analysis in partnership with @monotophic
ABLATE_SCORE=<manifest>replaces the decode with a teacher-forced sweep: foreach item it configures the ablation cells, runs one prefill, and reads the
final logits at every target position. That mode already exists on
dev. Whatit cannot do is produce evidence anyone can check. It accepts whatever a
permissive whitespace scan can make of a line, skips lines it cannot parse and
carries on, silently truncates an over-long cell list, and writes records naming
neither the config nor the manifest they came from. A partial run looks exactly
like a complete one — it exits 0 after writing nothing but a header.
This PR replaces that mode's implementation, and the change is visible to
anyone parsing its output. The artifact's schema goes from
coli-ablate/1tocoli-ablate/2, the mode's exit status is now its own result instead of aconstant 0, and inputs the old mode would have partly executed are now refused
as a whole.
docs/ENVIRONMENT.mddescribes the mode as it now behaves. We choseto replace the previously-implemented ablation mode rather than retaining it and
adding an alternative. I think that replacement is the cleaner/more-correct
choice, but understand if you object to that approach. Let me know if you want
the old ablation mode back in.
What the new mode gives you: the manifest is a defined format with an exact
field count and single-space separators, so the same content always produces the
same digest; every field is bounded against the loaded config before anything
runs; anything outside that is refused as a whole, naming the record at fault,
including a duplicate item id. One item may declare at most a million tokens, so
a corrupt or hostile manifest cannot ask the loader for an arbitrary allocation.
A CRLF terminator and a missing final newline are both accepted and normalised —
a host editor produces them without meaning to — and all three framings of the
same content bind the same digest. The artifact gains the digests of the config
and the manifest, the item and target counts it expects, and a terminal record
carrying the counts actually completed, so a truncated file is detectable.
Positions whose logits are not finite are refused by name instead of being
written out as unusable numbers, the top-k list holds real vocabulary entries
instead of padding a fixed width with sentinels, and the output path is created
exclusively, never truncated. Every refusal says why on stderr before the mode
returns non-zero.
The first commit adds the two small foundations that make the above checkable: a
classified reduction of a logit row (
logprob_row_checked), which reports whya row was unusable rather than only that it was, so a mode can refuse a position
by name instead of writing a NaN a reader cannot tell apart from a measurement;
and a short self-contained SHA-256 (
evidence_digest.h) so a mode can name theexact bytes it consumed without the engine taking on a dependency it otherwise
never needs. The plain sampling path is untouched, and the new path is pinned to
agree with it on every well-behaved row.
The reported numbers are unchanged. The negative log-likelihood and the
partition function are computed exactly as before, in double, and are only
printed at full precision now — a reader comparing old and new files sees more
digits of the same value.
Context. This PR is one of four independent contributions derived from a
single locally-verified working tree (offline evidence tooling, this engine
mode, two engine-transcript check tools, and a raw wire-format adapter). This
one carries the engine block: the ablation scoring mode, the row-status and
digest headers, and their tests. It uses one file from the evidence-tooling PR
(#1355, #1355): the round-trip test here runs the real producer against
that PR's
tools/check_ablate_evidence.py, so until that PR lands, thisbranch's engine build, its two C test binaries and the mode's dispatch checks
all stand, and that one round-trip case fails rather than skips — deliberately,
because a silent skip there would read as a pass on the only check that the
producer and the checker still agree. The others are proposed separately, each
with its own evidence.
Behavioral contract
one produce the same output, token for token, on the same container.
ABLATE_SCOREis set, and no other path reaches it.no input is partly executed.
nlland partition function are bit-for-bit whatdevcomputes.for each manifest framing the engine accepts, and all three bind one digest.
suites pass with the other three PRs of the set absent.
Structural changes on the default load path. The mode's digest is computed
only when
ABLATE_SCOREis set, so the load path does no new work on anordinary run. Eight things about it are nonetheless different from
dev, andyou should not have to find them yourself:
<stdarg.h>and<inttypes.h>;#include "evidence_digest.h"— a new file whosefunctions are plain
static;Cfggainedchar config_sha256[65], so everyCfgin the process grows;cfg_rootgained a third parameter;cfg_rootruns only when a caller passes a buffer;load_cfgreads one environment variable on every call to decide whether topass that buffer — one
getenvper model load;run_score's prefix probe passesNULLfor the new parameter;sample.hgained threestatic inlinefunctions and two types; that headeris compiled into
olmoe.cas well, where they are unused.Capstone matrix — one decisive artifact per claim.
dev(e1efc68, binary SHA-2566d90bfa4…) and one from this branch (58b1da45…), the same int3-spine/int4-expert container, the same request, the container's on-disk key/value cache cleared before each arm: both returned HTTP 200 and the same 24-token continuation, token for token, and each server's own stderr shows it computing its own prefill ([API] KV slot 0 prefix 0/4 token, prefill 4on both arms) — with a positive-coverage assertion, so an empty-vs-empty "match" cannot pass[CUDA] device 0withCOLI_CUDA=1, both HTTP 200, the same 24 tokens, cache cleared before each armtests/test_ablate_mode_gate.pyreads the engine source and asserts exactly one product entry into the mode plus one compile-gated adapter entry, and thatrun_ablate_scorehas exactly one caller inside the mode; it fails if a second call site appearsnllis stilldev's double-precision reductiontests/test_ablate_mode.cdrives a row through the real emitter whose logits are chosen so a single-precision intermediate differs, and compares the emitted text with==against an expectation computed in the test fromdev's formula; substituting the float intermediate fails itconfig_sha256really comes from the load pathconfig.json, callsload_cfg, and compares the field against an independently computed digest and a pinned literal; it also asserts the field is left unset when the variable is not settests/test_ablate_mode.c:287and in the tooling PR's suite, and reproduces outside both:printf 'coli-ablate-manifest/2\n0 3 2 0 0 1 2 3\n' | shasum -a 256→c63a48c375b14ca60f26c7e3c5dd36b5929ffaf669a45511c93deee6e8bbd5edcoli-ablate/2artifact with three logit records that the offline checker accepted against the model's ownconfig.json:[ablate-evidence] PASS manifest=4e18e3ab… items=1 targets=3, exit 0tests/test_ablate_mode.cruns 73 named checks with no model and no weights — the loader, the writer, the three accepted framings, each named refusal, and the dispatch contract;tests/test_logprob_status.cruns 99, including published SHA-256 vectors at the block boundary and both padding branchesHow the fleet comparison was set up, what it does and does not cover, and origin accounting
Container substitution, stated plainly. The CPU comparison did not run on the
fp8 container we first chose: an engine built from
devcannot load it (thekv_babsorb path refusesfmt=8without #1102), and it refuses a secondcandidate outright because an overlay shard duplicates tensor names. We
therefore ran the CPU arms on a container the base engine does load: an
int3-spine/int4-expert build of the same model. The differential is
between two engines on one container, so the substitution changes which weights
were decoded, not what is being compared.
The CUDA arms carry another change. So that the fp8 container would load at
all on the GPU host, both arms there were built from local integration branches
that add #1102's fp8 decode on top: one is
dev+ #1102, the other is thisbranch + #1102. Both arms carry it, so the difference between them is still
exactly this PR. Those integration branches are local only and are not proposed
anywhere.
Six launches, and what failed. Reaching the two green cells took six lane
launches. Four failed honestly and are worth naming because each was a refusal
we wanted: two container loads the base engine refuses by design, one cell whose
witness was written on a channel the server discards before it is readable (an
instrument defect on our side — a green there would have proved nothing, and it
failed instead of passing vacuously), and one arm refused by the engine's own
RAM guard while the previous arm was still releasing memory. The later CPU
re-run described above cost one more honest failure of the same kind: its first
launch failed its own content check because our cache-clearing record was written
in the wrong place in the log, which the check caught; the fixed relaunch is the
record cited here, and the failed launch's artifacts were kept.
What the comparison covers. Both hosts compared the generated continuation
of the same request across the two binaries with a positive-coverage assertion.
On the CPU host the comparison covers prefill as well as decode. The first
run of that cell had let the second arm resume the prompt's key/value state from
the first arm's on-disk cache, which meant only the
devbinary had actuallycomputed a prefill; we re-ran the cell with the container's cache file moved
aside before each arm (its size and SHA-256 recorded each time it was moved),
so neither arm could inherit the other's state. Both arms then reported the same
line in their own server stderr —
[API] KV slot 0 prefix 0/4 token, prefill 4— which is the server saying it found nothing to reuse and computed all four
prompt tokens itself. With that, the two binaries agree on a run in which each
one performed the whole computation:
match: true, positive_coverage: true,both HTTP 200, the same 24 tokens.
On the GPU host the first run had let both arms resume a warm cache, so we
re-ran that cell the same way: the container's cache file moved aside before each
arm (size and SHA-256 recorded each time). Both arms reported
[API] KV slot 0 prefix 0/4 token, prefill 4in their own server stderr, and thetwo binaries agreed on a run in which each performed the whole computation:
match: true, positive_coverage: true, both HTTP 200, the same 24 tokens. Thecache file each binary then wrote to disk had the same SHA-256 on that host.
fp8_format.hpairing. The two new Makefile rules here list every headertheir sources include,
quant.hamong them. If #1102 lands first,quant.hgains
fp8_format.hand these two rules should list it too, exactly as itssibling rules do — a one-line follow-up in whichever order the two land.
devload_cfground trip against an independent digest and a literalOrigin accounting. The mode's implementation, the two headers and their tests
are new content re-expressed onto current
dev. One behaviour ofdev's isdeliberately superseded (the old mode, as described at the top). One item is a
correction found during review rather than carried in: the offline checker
originally refused manifests with CRLF or without a final newline, which the
engine accepts —
dev's leniency is the contract of record, so thenormalisation now lives in one place and is pinned by a known answer on both
sides. Two committed pins (the
nllvalue and theload_cfgdigest) close gapsthat nothing guarded before. The Makefile change completes the prerequisite
lists of the two new rules, matching every sibling rule. No surrounding code was
reformatted.
Origin accounting — additions after the rebases onto the current
devevidence_digest.hamongcolibri's Makefile prerequisites, because upstream'snew Makefile-prerequisite test (build: list the headers each engine includes as Makefile prerequisites #1284) requires every header a source includes to be declared. No behavior change.
c/sample.hsubtracted two floatsbefore promoting to double; on widely spread logit rows the error reached 0.25 (about 4e-4 on moderate rows, negligible
on ordinary vocabularies). It now promotes the logit first; the value then agrees with a second double computation to a
few units in the last place. No number this PR writes to disk changes, because the evidence writer computes its own
double subtraction.
logprob_targetondevkeeps its existing arithmetic and is not touched here.Durable vs current state: the mode's format, its refusals, the digest
binding and the dispatch guard are durable. The binary digests, container
choice, host models and the 24-token continuation are current state, measured
2026-09-04 against base
e1efc68on a Linux CPU host (GCC) and an NVIDIA GB10host (NVCC); the cleared-cache re-runs on both hosts were measured 2026-09-05
with the same two binaries.