Skip to content

feat(engine): replace the ablation scoring mode with a checked, digest-bound one - #1356

Open
monotophic wants to merge 14 commits into
JustVugg:devfrom
monotophic:evidence/ablate-block
Open

monotophic wants to merge 14 commits into
JustVugg:devfrom
monotophic:evidence/ablate-block

Conversation

@monotophic

Copy link
Copy Markdown
Contributor

Authored by Claude Opus in Claude Code, analysis in partnership with @monotophic

ABLATE_SCORE=<manifest> replaces the decode with a teacher-forced sweep: for
each item it configures the ablation cells, runs one prefill, and reads the
final logits at every target position. That mode already exists on dev. What
it 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/1 to
coli-ablate/2, the mode's exit status is now its own result instead of a
constant 0, and inputs the old mode would have partly executed are now refused
as a whole. docs/ENVIRONMENT.md describes the mode as it now behaves. We chose
to 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 why
a 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 the
exact 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, this
branch'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

  • No default decode path changes what it computes: an unchanged binary and this
    one produce the same output, token for token, on the same container.
  • The mode runs only when ABLATE_SCORE is set, and no other path reaches it.
  • Every rejected input is rejected by name, on stderr, before a non-zero return;
    no input is partly executed.
  • The reported nll and partition function are bit-for-bit what dev computes.
  • Producer output passes an offline checker written independently of the engine,
    for each manifest framing the engine accepts, and all three bind one digest.
  • Everything that worked before still works: the branch builds and the existing
    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_SCORE is set, so the load path does no new work on an
ordinary run. Eight things about it are nonetheless different from dev, and
you should not have to find them yourself:

  1. two new unconditional includes, <stdarg.h> and <inttypes.h>;
  2. a new unconditional #include "evidence_digest.h" — a new file whose
    functions are plain static;
  3. Cfg gained char config_sha256[65], so every Cfg in the process grows;
  4. cfg_root gained a third parameter;
  5. the digest call inside cfg_root runs only when a caller passes a buffer;
  6. load_cfg reads one environment variable on every call to decide whether to
    pass that buffer — one getenv per model load;
  7. run_score's prefix probe passes NULL for the new parameter;
  8. sample.h gained three static inline functions and two types; that header
    is compiled into olmoe.c as well, where they are unused.

Capstone matrix — one decisive artifact per claim.

claim decisive evidence
decode is unchanged on a real model, CPU two servers on one Linux host, one built from dev (e1efc68, binary SHA-256 6d90bfa4…) 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 4 on both arms) — with a positive-coverage assertion, so an empty-vs-empty "match" cannot pass
decode is unchanged on a real model, CUDA the same comparison on an NVIDIA GB10 (sm_121) host against an fp8 container: both arms [CUDA] device 0 with COLI_CUDA=1, both HTTP 200, the same 24 tokens, cache cleared before each arm
the mode is unreachable without its variable tests/test_ablate_mode_gate.py reads the engine source and asserts exactly one product entry into the mode plus one compile-gated adapter entry, and that run_ablate_score has exactly one caller inside the mode; it fails if a second call site appears
nll is still dev's double-precision reduction tests/test_ablate_mode.c drives 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 from dev's formula; substituting the float intermediate fails it
the header's config_sha256 really comes from the load path the same suite writes a temp config.json, calls load_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 set
the manifest digest means the same thing in C and in Python the same 64-character known answer is pinned in tests/test_ablate_mode.c:287 and 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 256c63a48c375b14ca60f26c7e3c5dd36b5929ffaf669a45511c93deee6e8bbd5ed
producer output survives a real round trip on the Linux host, a real run over a real container produced a coli-ablate/2 artifact with three logit records that the offline checker accepted against the model's own config.json: [ablate-evidence] PASS manifest=4e18e3ab… items=1 targets=3, exit 0
the refusals are real, not decorative tests/test_ablate_mode.c runs 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.c runs 99, including published SHA-256 vectors at the block boundary and both padding branches
How 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 dev cannot load it (the
kv_b absorb path refuses fmt=8 without #1102), and it refuses a second
candidate 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 this
branch + #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 dev binary had actually
computed 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 4 in their own server stderr, and the
two binaries agreed on a run in which each performed the whole computation:
match: true, positive_coverage: true, both HTTP 200, the same 24 tokens. The
cache file each binary then wrote to disk had the same SHA-256 on that host.

fp8_format.h pairing. The two new Makefile rules here list every header
their sources include, quant.h among them. If #1102 lands first, quant.h
gains fp8_format.h and these two rules should list it too, exactly as its
sibling rules do — a one-line follow-up in whichever order the two land.

requirement instrument result
default-path neutrality old-vs-new binary differential on real containers, two hosts, two toolchains (GCC and NVCC), the key/value cache cleared before each arm on both hosts identical output, positive coverage on both; every arm computed its own prefill
no unreachable-mode dispatch source-level dispatch assertions in the gate test one product entry, one compile-gated adapter entry
numeric equality with dev committed row pin on a value where the two candidate reductions differ equal, and the mutation fails
digest binding through the production load path committed load_cfg round trip against an independent digest and a literal bound; default path leaves it unset
named refusals, no partial execution 73 named checks with no model green
round trip across all accepted framings producer and offline checker, three framings, plus an on-disk difference assertion one digest for all three
build hygiene full clean build of the branch and its suites see the checks run at the pushed head
Windows not built here; the file APIs used come through the project's own compatibility header and the manifest is opened in binary mode, so line endings behave identically unverified locally; CI's MinGW job is the closing evidence

Origin accounting. The mode's implementation, the two headers and their tests
are new content re-expressed onto current dev. One behaviour of dev's is
deliberately 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 the
normalisation now lives in one place and is pinned by a known answer on both
sides. Two committed pins (the nll value and the load_cfg digest) close gaps
that 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 dev

  • Dev-motion-caused: one commit lists evidence_digest.h among colibri's Makefile prerequisites, because upstream's
    new 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.
  • Correction found by re-review (this PR's own code): the shared logprob helper in c/sample.h subtracted two floats
    before 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_target on dev keeps 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 e1efc68 on a Linux CPU host (GCC) and an NVIDIA GB10
host (NVCC); the cleared-cache re-runs on both hosts were measured 2026-09-05
with the same two binaries.

monotophic and others added 14 commits September 5, 2026 14:13
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.
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