Skip to content

Write scores as a score directory - #416

Merged
luciaquirke merged 5 commits into
mainfrom
feat/magic-score-dirs
Aug 6, 2026
Merged

Write scores as a score directory#416
luciaquirke merged 5 commits into
mainfrom
feat/magic-score-dirs

Conversation

@luciaquirke

Copy link
Copy Markdown
Collaborator

Stacked on #415 (base feat/magic-per-token-per-query). Merge that first and this retargets to main cleanly.

Problem

MAGIC wrote a bare tensor to scores.pt, so every consumer had to recover from the shape what a score directory simply states. A 2-D tensor is [docs, seq_len] or [docs, queries] depending only on how the run was configured — which is why load_attribution_scores had to read config.yaml, fall back to the rank when there was none, and why scores_are_per_token needed the same treatment.

And the tensor was never self-contained anyway. It already leaned on config.yaml to disambiguate its own axes and on a doc_ids.pt sidecar to map shuffled chunks back to documents. It wasn't a simpler format than a score directory — it was an undocumented one.

Fix

Write the same score directory the scoring pipeline writes. info.json records attribute_tokens and num_scores, so both questions are answered by the store, and the reader collapses to: load it, negate if higher_is_better, report num_scores > 1.

Deleted: the .pt branch of load_attribution_scores, the config lookup and shape fallback behind it, scores_are_per_token's two non-directory branches, and read_first_step_config, whose only caller was that lookup.

Why the ragged store fits exactly

The one thing that made this non-obvious. MAGIC's weight grid is dense [rows, seq_len]; the token store is ragged, length - 1 values per row. They coincide, and not by luck:

  • weighted_causal_lm_ce reads example_weight[:, :-1], so a row's columns past length - 1 never receive gradient — precisely the rows compute_num_token_grads allocates.
  • to_grid restores width max(length - 1) + 1, which is the seq_len the grid started with, for both chunk_length > 0 and unpacked data.

So packing drops only structural zeros and the round-trip is exact in shape and value. test_save_magic_scores_round_trips_the_grid asserts that for single- and multi-query.

doc_ids

Moves into the directory as scores/doc_ids.npy, in the grid's shape, so a per-token store carries everything needed to aggregate per document rather than depending on a sibling file.

Output layout

run_path/scores/
  info.json      attribute_tokens, num_scores, dtype
  scores.bin     memmapped scores
  offsets.npy    per-token only
  doc_ids.npy    per-token only

Verified end to end on CPU for all three modes — per-token (5, 8), per-doc (5, 1), per-token per-query (5, 8, 2) — each loading back at the shape MAGIC used to write, with multi_query and scores_are_per_token reported from info.json.

Testing

110 passed, 38 skipped across every test file touching magic, validate or scores. Tests that covered the removed .pt path are gone; those that read scores.pt now read the directory, including test_distributed_magic.py. docs/magic.rst "Output files" rewritten, sphinx clean. pre-commit --all-files clean.

🤖 Generated with Claude Code

@luciaquirke
luciaquirke force-pushed the feat/magic-score-dirs branch from bb38a46 to d845688 Compare August 6, 2026 11:35
@luciaquirke
luciaquirke changed the base branch from feat/magic-per-token-per-query to main August 6, 2026 11:35
luciaquirke and others added 2 commits August 6, 2026 21:51
MAGIC wrote a bare tensor to scores.pt, so every consumer had to recover
from the shape what a score directory simply states. A 2-D tensor is
[docs, seq_len] or [docs, queries] depending only on how the run was
configured, which is why load_attribution_scores had to read config.yaml,
fall back to the rank when there was none, and why scores_are_per_token
needed the same treatment. The tensor was never self-contained anyway: it
already leaned on config.yaml to disambiguate and on a doc_ids.pt sidecar
to map shuffled chunks back to documents.

Write the same score directory the scoring pipeline writes. info.json
records attribute_tokens and num_scores, so both questions are answered by
the store, and the reader collapses to: load it, negate if
higher_is_better, report num_scores > 1.

Per-token scores pack into the ragged token store exactly. The weight grid
is [rows, seq_len] and weighted_causal_lm_ce reads example_weight[:, :-1],
so a row's columns past length - 1 never receive gradient — precisely the
rows compute_num_token_grads allocates. to_grid then restores the same
width, since max(length - 1) + 1 is the seq_len the grid started with. The
round-trip is exact in shape and value, tested both ways.

doc_ids moves into the directory as doc_ids.npy, in the grid's shape, so a
per-token store carries everything needed to aggregate per document.

Deleted along the way: the .pt branch of load_attribution_scores, the
config lookup and shape fallback behind it, scores_are_per_token's two
non-directory branches, and read_first_step_config, whose only caller was
that lookup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The old name said what was loaded but not in which orientation, and the
orientation is the part a caller gets wrong. Scores come back in the
loss-diff convention: higher_is_better stores are negated on load, and
validate correlates them against baseline - loss, so a proponent lands
negative. The name now carries that, and the docstring states it outright.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@luciaquirke
luciaquirke force-pushed the feat/magic-score-dirs branch 2 times, most recently from c976dee to dc5af7f Compare August 6, 2026 12:54
It is a thin sign-normalising wrapper over load_scores, which lives in
data alongside the store format it reads — Scores, offsets, to_grid — so
the two belong together. Nothing about it is validation-specific; magic
imported it from validate only because that is where it happened to sit.

No new dependency edge: data already imports from config.config, and the
config layer does not import data, so ScoreConfig and load_subconfig come
along without a cycle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@luciaquirke
luciaquirke force-pushed the feat/magic-score-dirs branch from dc5af7f to ad09dba Compare August 6, 2026 12:55
Deleting the .pt branch made every run finished before score directories
unreadable, and not with a message: load_scores(Path("scores.pt")) opens
scores.pt/info.json and raises NotADirectoryError.

Restore the read path only, behind a suffix check, with the ambiguity
resolution it had: a 2-D tensor is per-token unless the run config beside
it records query_method: none. Marked for removal in December 2026.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@luciaquirke luciaquirke changed the title feat(magic): write scores as a score directory Write scores as a score directory Aug 6, 2026
# Conflicts:
#	tests/test_per_query_magic.py
@luciaquirke
luciaquirke merged commit 4d5ee08 into main Aug 6, 2026
8 checks passed
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.

1 participant