Skip to content

feat(distill): schema-uniform v2 records and an append-only outcome sink - #180

Merged
SkyeAv merged 2 commits into
mainfrom
distill/schema-v2-records
Sep 15, 2026
Merged

SkyeAv merged 2 commits into
mainfrom
distill/schema-v2-records

Conversation

@SkyeAv

@SkyeAv SkyeAv commented Sep 15, 2026

Copy link
Copy Markdown
Owner

Gives --distill a stable, self-describing corpus contract so downstream tooling (and a future training-data pipeline) can trust the files without sniffing them: every record line carries the same canonical key set, and each run's outcome lands in a sibling file instead of being inferred from state.json.

Schema-uniform v2 records

  • Stable key set: every line in records.ndjson now carries exactly the 13 RECORD_KEYS (record_type, schema_version, run_id, timestamp, purpose, call_index, messages, token_usage, input_tokens, output_tokens, n_messages, pmc_id, model_id), SCHEMA_VERSION = 2, with an explicit null where a value is unknown — a key never first appears partway down an append-only file, which is what made v1 corpora unexportable.
  • Run identity: begin_run() opens a per-article scope and stamps every record with run_id ("<invocation_id>:<pmc_id>") plus pmc_id/n_messages/token scalars so a corpus can be grouped and filtered without re-parsing messages.
  • Recorder hygiene: unknown meta keys are logged as a warning instead of silently widening the schema; the previous ChatML-only v1 shape still parses (readers tolerate supersets, writers never emit them).

Append-only outcome sink

  • outcomes.ndjson: a sibling file in the same directory gains record_outcome() + canonical_struct() with a fixed 35-key OUTCOME_KEYS set (build verdict scalars, tool-call tallies, gate thresholds, package versions) — schema-uniform the same way, so outcome lines are joinable, exportable, and diffable.
  • Shared guarded append: both files go through _append_json_line, which returns bool and only advances call_index when a line actually lands on disk — a failed write is logged, never raised, and never desynchronizes the counter.

Design

  • Recording stays zero-dependency and fail-soft: stdlib only, a capture failure must never break or slow a run (the outcome sink is filled by later layers; this layer ships the schema and the sink).
  • records.ndjson stays append-only forever; outcomes go to the sibling file, never a rewrite.

Testing

  • uv run pytest tests/test_distill.py -q -n0 --no-cov → 26 passed (v2 shape, explicit-null invariants, unknown-key warning, guarded-append counter semantics, v1 back-compat).
  • Full gate at the stack tip: uv run pytest -q → 1512 passed, 15 skipped, 0 failed; uv run pyright → 0 errors; ruff check + ruff format --check → clean.

…ry call

`--distill` records were not joinable to the run that produced them and were not
schema-stable, so the captured corpus could not carry an outcome or be loaded
reliably for fine-tuning.

Make every appended line self-describing and uniform:

- `RECORD_KEYS` is now the canonical ordered key set and every record emits every
  key, with an explicit `null` where a value is unknown. This is load-bearing, not
  cosmetic: `datasets.load_dataset("json")` infers its features from the first
  block of the first file and raises `CastError` when a later block of an
  append-only corpus introduces a column the inferred schema lacks.
- Add `record_type` (a content discriminator, so a training row is separable from
  an outcome row without inspecting the filename), `schema_version`, and `run_id`
  (`"<invocation_id>:<pmc_id>"`, the join key to a run's outcome, opened by the new
  no-I/O `begin_run` scope).
- Derive `n_messages`, `input_tokens` and `output_tokens` onto the record itself so
  per-example size is queryable without re-parsing `messages` — TRL's
  `SFTConfig.max_length` defaults to 1024 and silently drops fully-masked examples.

This also closes a latent v1 drift: `cli.py` wraps the judge and reflexion models
with no `meta` while `agent.py` passes `pmc_id`, and `model_id` was added only when
recoverable, so one append-only file already held three different key sets. All
records are now uniform, pinned by
`test_records_are_schema_uniform_across_purposes`.

v2 is strictly additive: the seven pre-existing keys keep their names, types and
meanings, `call_index` semantics are unchanged, `distill.py` gains no third-party
import, and every write path stays fail-soft so recording can never break a batch.

Testing: `uv run pytest tests/test_distill.py -q -n0 --no-cov` -> 19 passed (12
pre-existing tests unmodified, +7 new); `uv run pytest -q` -> 1406 passed, 15
skipped, 0 failed (baseline 1399); `uv run pyright` -> 0 errors; `uv run ruff check .`
and `ruff format --check .` clean.
…es.ndjson

The captured corpus still knew nothing about how each run ended, so an example
could not be weighted by the quality of the KG it produced.

Add `DistillRecorder.record_outcome()`: one schema-uniform line per supervisor
run in a sibling `outcomes.ndjson` (never interleaved into `records.ndjson`,
which stays a pure ChatML training file for Unsloth Studio and
`distill-export`), joined back onto records at weigh time on `run_id`.

- `OUTCOME_KEYS` (35 figures: coverage, Biolink validity incl. strict,
  demoted-edge rate, node/edge counts, unresolved/advice counts, attempts,
  config chars + sha256, provenance, QC, tool-call tallies, tokens, steps,
  judge score/dimensions, gate thresholds, versions) — every key on every
  line with an explicit `null` when unmeasured, because `datasets` infers
  features from the first block of the first file and raises `CastError` on a
  later column; only figures already computed at the run's status-decision
  point are captured (no new build/audit/KGX read).
- Nested structs (`tool_calls`, `gate`, `versions`) are pinned to fixed
  sub-key tuples and never emitted as bare `null`, so a struct column cannot
  be silently JSON-demotioned to a string by `on_mixed_types="use_json"`.
- `record_type`/`schema_version` are forced, `run_id` falls back to the open
  `begin_run` scope, `timestamp` to write time; outcome lines never advance
  `call_index`, which now counts only records that actually reached disk.
- Capture stays zero-dependency and fail-soft: one guarded append path shared
  by `record` and `record_outcome`, failures logged, never raised into a run.

Testing: `uv run pytest tests/test_distill.py -q -n0 --no-cov` -> 24 passed
(19 unmodified + 5 new); `uv run pytest -q` -> 1411 passed, 15 skipped, 0
failed; `uv run pyright` -> 0 errors; ruff check + format --check clean.
@coderabbitai

coderabbitai Bot commented Sep 15, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 5867763f-5f9f-49f8-b067-a7f5f8daaf73


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@SkyeAv
SkyeAv added this pull request to stack #186 September 15, 2026 17:55
@SkyeAv SkyeAv changed the title distill/schema v2 records feat(distill): schema-uniform v2 records and an append-only outcome sink Sep 15, 2026
@SkyeAv
SkyeAv marked this pull request as ready for review September 15, 2026 17:55
@SkyeAv
SkyeAv merged commit b401d02 into main Sep 15, 2026
5 checks passed
SkyeAv added a commit that referenced this pull request Sep 15, 2026
Cut 19.0.0 and bump the package version in pyproject.toml, CITATION.cff,
and uv.lock.

Major: five breaking changes ship. The `tablassert` console command now
requires the optional `[cli]` extra, since `cyclopts` and `rich` left the
base install (#187). Level-one normalization redefines fullmap keys as
cleaned Unicode-lowercase, Porter2-normalized, byte-ordered token sets and
moves the database to schema v6, so every schema-v5 fullmap is rejected and
must be rebuilt (#171, #172, #174, #175, #176). `build-fullmap --aria2c`
is gone -- aria2c is used automatically whenever the `[aria2]` extra is
installed (#178) -- and `--taxon-allowlist` is gone because the built-in
top-100 experimental-taxon allowlist now applies to every build, guarding
both reuse paths by `META.taxon_allowlist` identity (#153). Logging now
requires the `[log]` extra and is fully disabled without it (#152).

Features: `tablassert.net` is a new stdlib-only transient/permanent
classification and retry seam shared by the agent and the BABEL downloader
(#163, #164), with one bounded jittered retry layer across the inner agent,
reflexion, and judge (#167) and machine-readable `error_code` values on
skipped checkpoint records (#166). `build-kg` gained an automatic ephemeral
shared-prefix TCode cache keyed by content-addressed XXH64 op digests
(#155-#159, #161). The `--distill` corpus became schema-uniform v2 with a
sibling `outcomes.ndjson`, a deterministic `RewardConfig`-tunable reward,
and the new zero-dependency `tablassert distill-weigh` command (#180-#183).
`CLASS_FIELD_OVERRIDES` grants the DAKP sparse qualifier stack on the pinned
association classes (#188).

Fixes: `distill-export` partitions its corpus by content and unions the
schema across record files, so a mixed v1/v2 directory no longer CastErrors
or silently stringifies a column (#184); BABEL retry warnings survive the
loguru sink; PMC downloads are idempotent, atomic, and bounded-parallel
(#165); reflexion and judge prompts are hard-bounded (#168).

Changelog:
- versioned the Unreleased section as 19.0.0 - 2026-09-15
- merged three duplicate `### Added` blocks into one
- moved the two `BREAKING:` entries from `### Changed` into
  `### Breaking Changes` and added a `**Migration:**` note to all five
- added PR links to all 17 entries, which carried none
- added the missing entries: the distill v2 corpus/reward/distill-weigh
  stack (#180-#183), the distill-export schema-drift fix (#184), the
  TCode run-cache detail (#155-#161), and the README badge removal (#154)

Docs: none needed here. Every shipped doc change landed with its own PR,
and the docs source-of-truth gate passes against the bumped tree.

Testing:
- make check -> exit 0
- uv run ruff check . -> All checks passed!
- uv run pyright -> 0 errors, 0 warnings, 0 informations
- uv run pytest -> 1622 passed, 52 skipped
- cargo test --manifest-path rust/Cargo.toml -> 156 passed, 0 failed
- cargo clippy --all-targets -- -D warnings -> clean
- uv lock --check -> resolved 169 packages, lock current
- uv run mkdocs build --strict -> exit 0
- docs SSOT + CLI coverage after the changelog edit -> 204 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