Skip to content

feat(distill): make distill-export schema-robust across a drifting corpus - #184

Merged
SkyeAv merged 1 commit into
distill/weigh-clifrom
distill/export-hardening
Sep 15, 2026
Merged

SkyeAv merged 1 commit into
distill/weigh-clifrom
distill/export-hardening

Conversation

@SkyeAv

@SkyeAv SkyeAv commented Sep 15, 2026

Copy link
Copy Markdown
Owner

An append-only corpus is the point of --distill, but datasets.load_dataset("json") infers its column schema from the first block of the first file only and then casts every later table to it — so the moment v1 and v2 records (or any two differently-shaped files) share a directory, export either raises CastError or, worse, silently JSON-encodes a varying-type column into a string. Outcome files sitting in the same corpus directory made a mixed corpus unexportable outright. This hardens distill-export to treat schema drift as a first-class case.

Content-based partition

  • iter_record_files + is_outcome_file (inspects the first non-blank line's record_type) partition the directory, so a renamed outcome file still partitions correctly; only record files are loaded.
  • An outcomes-only directory exits 2 with a message distinguishing "only outcomes here" from the legacy empty-directory case (whose message is byte-identical to before).

Corpus-wide schema union

  • Every record file is read with the pure read_ndjson, unioned via normalize_rows (first-appearance key order), and written as one uniform temporary NDJSON in a system temp dir — never inside --distill-dir, removed via try/finally on success and failure — and that single file is the only thing passed to load_dataset. A v1-only corpus still exports: normalization collapses it to its own union.

Type-conflict rejection before the extra

  • detect_type_conflicts runs before extras.require("distill") and the datasets import: a key with more than one distinct non-null type exits 2 naming the key and the types with the why (datasets would silently JSON-encode a varying-type column into a string). Base-environment users get the actionable data error, not an extras error.
  • Record files that exist but contain zero rows also exit 2 — previously a bare StopIteration from inside datasets' JSON reader.

Compatibility

  • Flags, defaults, exit codes, [distill] preflight ordering, and the success-message shape are unchanged; the message's file count now correctly names record files only. distill-weigh output (which legitimately carries extra columns) exports with no new flag — the weigh → export composition works as-is.

Docs

  • docs/cli.md ## distill-export prose updated (outcome skip, normalization, type-conflict rejection); option table unchanged — no flags added.

Testing

  • uv run pytest tests/test_cover_cli.py -q -n0 --no-cov → 54 passed (4 new base-env tests: outcome skip via stubbed datasets, outcomes-only exit 2, pure normalize across v1+v2, type-conflict exit 2).
  • uv run pytest tests/test_distill.py tests/test_distill_weigh.py -q -n0 --no-cov → 64 passed incl. 2 new [distill]-gated round-trips: a v1-file-first corpus that naive load_dataset CastErrors on exports with the union schema, and the weigh→export composition.
  • Full gate at the stack tip: uv run pytest -q → 1512 passed, 15 skipped, 0 failed; uv run pyright → 0 errors; mkdocs build --strict → exit 0.

…rift

An append-only corpus is the whole point of `--distill`, but
`datasets.load_dataset("json")` infers its column schema from the FIRST BLOCK
OF THE FIRST FILE ONLY and then casts every later table to it — so the moment
v1 and v2 records (or any two differently-shaped files) share a directory,
export either raises CastError or, worse, silently JSON-encodes a
varying-type column into a string. Outcome files sitting in the same corpus
directory made it worse: a record-shaped corpus could not be exported at all
without manually moving files.

`distill-export` now:

- partitions the directory by CONTENT (`is_outcome_file` inspects the first
  non-blank line's record_type, so a renamed file still partitions correctly)
  and loads only record files; an outcomes-only directory exits 2 with a
  message distinguishing it from an empty one;
- reads every record file with the pure `read_ndjson`, unions the corpus
  schema with `normalize_rows`, writes ONE uniform temporary NDJSON (in a
  system temp dir, never inside --distill-dir, always removed via
  try/finally), and passes only that single file to `load_dataset` — the
  schema now comes from the whole corpus, not the first 10 lines of one
  file. A v1-only corpus still exports: normalization collapses it to its
  own union;
- rejects type conflicts loudly: `detect_type_conflicts` runs BEFORE the
  [distill] preflight and datasets import, so a column that varies type
  (e.g. pmc_id int vs str) exits 2 naming the key and the types with the
  why, instead of letting datasets silently stringify the column;
- fails loud on record files that exist but contain zero rows (previously a
  bare StopIteration from inside datasets' JSON reader).

Flags, defaults, exit codes, preflight ordering, and the success-message
shape are unchanged; the message's file count now correctly names record
files only. `distill-weigh` output exports with no new flag.

docs/cli.md `## distill-export` prose updated in the same commit (outcome
skip, normalization, type-conflict rejection); option table unchanged.

Testing: `uv run pytest tests/test_cover_cli.py -q -n0 --no-cov` -> 54 passed
(4 new base-env tests: outcome skip via stubbed datasets, outcomes-only exit
2, pure normalize step across v1+v2, type-conflict exit 2);
`tests/test_distill.py tests/test_distill_weigh.py` -> 64 passed (2 new
[distill]-gated round-trips: v1-file-first corpus that naive load_dataset
CastErrors on; weigh->export composition — both genuinely run locally);
docs SSOT gate -> 167 passed; `uv run pytest -q` -> 1497 passed, 15 skipped,
0 failed; pyright 0 errors; ruff check + format clean; mkdocs strict clean.
Independent Tier-2 review verified all seven acceptance points against
source (cli.py:1384-1426) and reproduced a real-CLI artifact: mixed corpus
exit 0 with union schema (v1 rows explicit-null on run_id), type-conflict
exit 2 naming key+types, outcomes-only exit 2, no temp residue, corpus dir
untouched.
@SkyeAv
SkyeAv added this pull request to stack #186 September 15, 2026 17:55
@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: d70110fd-5460-4850-8149-007443dd7752


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 changed the title feat(distill): [US-007] harden distill-export against corpus schema drift feat(distill): make distill-export schema-robust across a drifting corpus Sep 15, 2026
@SkyeAv
SkyeAv marked this pull request as ready for review September 15, 2026 17:55
@SkyeAv
SkyeAv merged commit 9ae9c3e 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