Skip to content

Feat/quality based upsampling - #461

Open
rrutmann wants to merge 34 commits into
mainfrom
feat/quality-based-upsampling
Open

Feat/quality based upsampling#461
rrutmann wants to merge 34 commits into
mainfrom
feat/quality-based-upsampling

Conversation

@rrutmann

Copy link
Copy Markdown
Collaborator

What does this PR do?

This PR adds quality-based document selection and up/downsampling to the data preprocessing pipeline, letting an ablation choose which documents enter a training blend by thresholding quality annotations and native corpus metrics, and how heavily each surviving subset is repeated. It was run end to end on the 20.21 TB annealing blend: 19 datasets, 16 annotated at 100 % join coverage, 1.64 T tokens packed into 6.0 TB, with packed token counts landing within −0.56 % of the pipeline's own estimates and document counts matching exactly.

The design keeps selection virtual for as long as possible. A per-document sidecar records where each document is and what it is worth; a contingency cube aggregates that so a threshold can be costed in seconds; and only apply materialises anything, writing filtered .idx files that name the surviving byte ranges. The source corpora are never written to. Trying a different threshold costs 14 seconds rather than a repack.

General Changes

  • Corpus registry (registry.py) declaring, per dataset, where its files are and how its documents key to external annotations. Six key kinds were needed in practice, including a corpus keyed by a hash of its own text and a translated corpus keyed through the English original it came from.
  • Token calibration (tokens.py) measuring bytes-per-token per size stratum, sampled at offsets spread across each file, taking the document containing each offset so selection is length-proportional. A corpus's ratio is not one number — FineWiki runs 3.6 for documents under a kilobyte and 15.8 above 256 KB.
  • Sidecar builder (sidecar.py) writing one parquet row per document with position, length, estimated tokens, join key and native metrics. The only stage that reads the raw corpora.
  • Source file manifest (file_manifest.py) pinning the file list a sidecar was built against, plus quality verify-sidecar, which seeks to recorded offsets and compares against the recorded text length.
  • Bucketed annotation join (annotation_join.py) partitioning annotation splits by a hash of the key, then attaching labels with index_in/take, scanning bucket files in bounded groups.
  • Cube (cube.py) aggregating a sidecar into one row per distinct combination of thresholdable fields.
  • Selection (selection.py): ordinal and numeric predicates, evaluate_blend, per-predicate attribution with a pairwise overlap matrix, and repetition accounting that multiplies each ratio by the number of passes a run makes over the blend.
  • Quality-aware upsampling curves (upsampling.py) making the repeat factor rise with quality instead of being one number per dataset, following Dolma 3 / Olmo 3 (arXiv:2512.13961).
  • Materialisation (materialize.py) writing filtered indexes and a manifest, one index tree per quality bucket when a curve is used.
  • WeightedCombinedDataset (dataset.py) combining packed datasets with float repeat factors, using a seeded affine permutation for partial passes so O(1) memory holds at any scale.
  • CLI: modalities quality {calibrate, build-sidecar, verify-sidecar, bucket-annotations, join-annotations, build-cube, preview, apply, write-packing-configs}.
  • SLURM runbook and helpers under config_files/data_preparation/quality/slurm/, including pack_many.py, which loads the tokenizer once per process rather than once per config — 375 core-hours of startup at this blend's 54,738 configs — and scan_pbins.py / verify_blend.py / load_blend.py for post-pack verification.
  • 190 tests across tests/dataloader/preprocessing/quality/ and test_weighted_combined_dataset.py, including regressions for the three failures that only appeared on the full production run.

Breaking Changes

None. Everything is additive: a new quality command group, a new WeightedCombinedDataset component, and new config files. No existing entry point, config schema or dataset class changes behaviour.

Checklist before submitting final PR

  • My PR is minimal and addresses one issue in isolation
  • I have merged the latest version of the target branch into this feature branch
  • I have reviewed my own code w.r.t. correct implementation, missing type hints, proper documentation, etc.
  • I have run a sample config for model training
  • I have checked that all tests run through (python tests/tests.py)
  • I have updated the internal changelog (CHANGELOG_DEV.md)

rrutmann and others added 30 commits August 17, 2026 12:40
Builds a training blend by filtering documents on quality signals and
choosing how heavily each dataset is sampled, with a fast preview of the
resulting token budget.

Selection addresses two kinds of signal with one syntax: metrics a corpus
already carries in its records, and external per-document annotations that
are joined on by key. Four key kinds cover the corpora we have, including
those that store no identifier at all and one whose identifier points into
a separate source corpus.

The source data is never copied or modified. Selection emits a filtered
.idx, and PackedDataGenerator already tokenizes exactly the documents its
index lists, so pack_encoded_data consumes the output unchanged and no
packing code was touched.

Previewing is instant because the per-document table is aggregated into a
cube once; a threshold on a bin edge is answered exactly, one inside a bin
is reported as interpolated rather than silently guessed.

WeightedCombinedDataset applies the ratio at training time, so a dataset
can contribute 2.5 or 0.3 epochs without being duplicated on disk. This
retires the _epoch_1/_epoch_2 directory convention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Profiling the pipeline against the real 43 TB blend rather than a fixture
found three stages far slower than they needed to be, two of which could
not be parallelised at all.

Native metrics declared as a plain field path are now read by direct
dictionary lookup instead of jq. jq re-serialises the whole document on
every call, which on a 21 KB record cost more than twenty times the rest
of building a sidecar row: 29 MB/s with jq against 374 MB/s without, 13x
end to end. Complex patterns still use jq, and the builder warns when one
does, because that pattern then dominates the stage.

build_cube groups with Arrow's kernels instead of a Python loop over
documents, batching row groups so cardinality saturates before each pass:
246k to 2.39M rows/s. That stage was not shardable, so its 8.5 hours were
a hard floor; it is now under an hour.

build-sidecar and the new bucket-annotations stage both take
--shard_id/--num_shards. Sidecar work is divided per file across every
dataset, so one array covers the whole blend rather than leaving a floor
of the slowest dataset. Bucketing moved out of join-annotations, which now
refuses to run against an incomplete bucketing run rather than silently
dropping the annotations a missing task was carrying.

One-time setup goes from ~88 h to ~4 h on two nodes. Previewing a
selection is unchanged at ~10 s, since it only reads the cubes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the four array scripts and a runbook for running the quality pipeline
against /data/annealing, following the numbered-sbatch convention already
used for the long-context preprocessing.

Two environment findings are recorded because they cost time to discover.
/home/richard.rutmann/data is a symlink to /data, so the registry's
existing paths already point at the right tree. And nemo_25_11.sif cannot
run this branch -- it ships torch 2.9 where modalities needs >=2.10 -- so
the runbook uses a plain CPU venv instead, these stages having no use for
a GPU.

Also adds the packing template the pipeline needs for calibration and for
generating per-file packing configs. Its tokenizer is a decision, not a
default: every token figure downstream depends on it and a wrong choice
fails silently, so the file says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ran the pipeline against /data/annealing/italian/Finewiki end to end. It
works, and it corrected something I had documented too narrowly.

Duplicate annotation keys are not specific to the Nemotron-CC split as the
registry implied: the finewiki split reported 868,586 duplicates in 43.1 M
rows. The join already keeps the first occurrence and reports the count,
but the docs now say to expect this generally and to check join_report.json
rather than assume a one-to-one join.

Also records measured stage timings, and that preview pays ~6 s of import
overhead before doing any work, so a full-blend preview is nearer 15-20 s
than the 10 s the cube arithmetic alone implies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
quality calibrate read 20,000 lines from every file of a dataset to build a
2,000-document sample, so its cost tracked the file count rather than the
sample size. Over the real blend that was 30.3 TB and ~30 hours; dolmino
alone, at 40,003 files, was 26 TB of it. Found because a real run was still
going after an hour with 9 of 19 datasets done.

The sampler now probes at most 32 files, spaced evenly across the dataset,
reading only enough lines from each to fill the sample. Full calibration of
all 19 datasets takes 4 minutes and reads about 1 GB. Spread is preserved
and the seeded trim keeps it reproducible.

calibration.yaml is also written after each dataset rather than once at the
end, so interrupting the stage no longer discards what it measured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
run_all_timed.sh runs the stages in order, prints the elapsed time for each
and a summary at the end. Array jobs go through sbatch --wait so the timing
is the job's real duration rather than the submission's, which also gives
the ordering steps 4 and 6 onwards need.

Takes step ids to run a subset, stops at the first failure while still
printing the summary, and accepts REGISTRY/SELECTION/TOKENIZER_CONFIG
overrides so a blend variant needs no edits to the shared configs. The
sbatch scripts honour the REGISTRY override too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The README said ~3 h on 64 tasks. The real run projects ~7 h, and the reason
is an arithmetic error rather than anything being slow: the storage floor was
derived from the blend's 43 TB read once, but the first build-sidecar reads
every file twice -- once for IndexGenerator to write the .idx, then once to
build the sidecar. The floor is 2 x 43 TB / 3.8 GB/s, about 6.3 h.

Measured mid-run: 51.3 % of the blend's bytes in 3.77 h, which is 3.28 GB/s
of actual reads against a measured ceiling of 3.81 GB/s. The stage is
storage-bound at 86 % of what the filesystem delivers, so more tasks would
not help.

Also records that the index pass is not wasted: pack_encoded_data needs those
.idx files and later runs reuse them, so a second build-sidecar over the same
data is roughly twice as fast.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first real build-sidecar run took ~15 h against a ~7 h projection. The
cause was placement, not throughput: SLURM packed 12 of the 64 tasks onto one
node and 8 onto another while four nodes sat idle, because at 2 CPUs per task
16 fit on a node. Each of those tasks got ~95 MB/s where a single stream gets
282 MB/s, so the job spent nine hours in a tail running at a third of its
initial speed.

Both array scripts now request 8 CPUs per task. A task is single-threaded and
needs one; the request is there to cap tasks per node at 4 so an array of 64
spreads across the cluster.

The underlying mistake was benchmarking storage on the login node and assuming
the 3.8 GB/s aggregate transferred to compute nodes. It does not: per-node
bandwidth binds first, which is why raising the array size would not have
helped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Companion to f8728e3. The estimate assumed the 3.8 GB/s aggregate measured
on the login node transferred to compute nodes; it does not. Per-node
bandwidth binds first, so packing tasks onto few nodes caps the job well
below the cluster's capacity, and raising the array size does not help.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bucket-annotations OOM-killed all 64 tasks of a real run at 24 GB each. The
writer flushed a bucket once it held 100,000 rows, which bounds nothing:
spread 50 M rows over 1024 buckets and each holds ~49 k, so no bucket ever
reaches the threshold and the whole input accumulates as Python dicts until
close.

The cap is now on the total buffered across all buckets, and reaching it
flushes everything. On the exact shard that failed -- 50 M rows, 1024 buckets
-- peak RSS is 2.36 GB instead of unbounded, at an unchanged 121k rows/s.

bucket_annotations now also refuses to write into a directory holding output
from a run with a different num_shards, since a sharded run cannot clear the
directory and the leftovers would be silently mixed into a bucket.

The completeness guard from the earlier sharding work did its job: the join
refused the partial buckets rather than producing sidecars with no labels.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mixed-array-size guard from 6d9e45d read every _meta.*.json with a bare
json.loads, while the writer used Path.write_text, which truncates before
writing. Sibling tasks of the same array read those files, so 12 of 64 tasks
of a real run died on an empty file with JSONDecodeError.

Metadata now goes to a per-task .tmp and is renamed onto the final name, so a
reader sees either the old file or the complete new one. Both readers skip a
file they cannot parse and ignore *.tmp; for read_bucket_metadata that is the
safe direction, since an unread file leaves its shard id unseen and the run
reports incomplete rather than joining missing annotations.

Verified on the real finewiki split: four sequential shards into one
directory, 43.1 M rows, metadata reading back complete with a matching total
and no temporary file left behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The runbook told the reader to export six variables by hand in every shell. A
fresh shell without them turns "$QDIR/slurm/x.sbatch" into "/slurm/x.sbatch",
and "rm -rf $WORK/buckets" into "rm -rf /buckets". Both happened; the second
was harmless only because /buckets does not exist.

env.sh sets the variables, fills in only what is unset, and refuses to
continue if any is empty or points at a missing path, so the failure is a
clear message rather than a confusing sbatch error or an rm against the wrong
directory.

reset_buckets.sh replaces the bare rm in the instructions. It requires WORK
via "${WORK:?}", rejects paths like /buckets outright, reports what it is
about to delete, and leaves the sidecars alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
join_annotations looped sidecar parts outside and annotation buckets inside,
so the entire bucketed split was read once per part -- and since a part's
documents hash across every bucket, those were full re-reads. Measured on the
real blend: 454 TB, 451 hours. nemotron-cc alone was 5,319 parts against a
23 GB split.

Parts are now processed in batches and each bucket is read once per batch,
taking the amplification from the part count to the batch count: 5.9 TB and
5.8 h serial. Buckets are filtered with is_in before anything reaches Python,
so memory follows the batch rather than the bucket, and bucket file lists are
globbed once instead of per batch.

Duplicate keys are counted once among the keys the join wants, rather than
afresh per part: finewiki-it reported 868,586 where the real figure is 36,747.

Adds 3a_join_annotations.sbatch, one array task per annotated dataset with the
dataset resolved from the registry, so wall time is the slowest dataset (~2 h)
rather than the sum, plus 3b_build_cubes.sbatch to follow it.

Verified on the real finewiki-it sidecar: 1,799,759/1,799,759 annotated in
48 s, against 107 s for a quarter as many documents before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A nemotron-cc join reached 5319/5319 parts and was killed by a 12-hour limit
during its final write-back, leaving 10 of 5,319 parts unlabelled. build-cube
then died on that with KeyError: Field "educational_value" does not exist,
after building 9 cubes and before attempting 6 healthy datasets.

join-annotations --resume skips parts that already carry the label columns.
Finishing the interrupted run took 9 minutes instead of 12 hours, skipping
5,309 parts. Off by default, because resuming after re-bucketing would keep
the old labels.

build_cube now reads every part's schema instead of trusting the first, and
raises CubeError naming the dataset, the missing columns and the --resume
command that fixes it. build_cubes builds every healthy dataset, reports the
failures together and re-raises, rather than losing six buildable cubes to one
bad dataset.

Join reports are written per dataset as well: sixteen parallel --only tasks had
been overwriting one shared join_report.json.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
evaluate_blend caught the SelectionError a cube raises for an ungrouped field
and quietly scanned the per-document sidecar instead. That is exact but reads
every document, so a preview advertised as seconds ran past ten minutes on the
real blend: nemotron-cc thresholded commercial_bias, which the join attaches
but the cube does not group, and dolmino thresholded dclm_plus2, which exists
only under one subdirectory and was null in all of 400 sampled parts.

The fallback is now opt-in via --allow_fallback. Without it, every
unanswerable predicate is reported together with the field, the dimensions the
cube does carry, and the document count a scan would read. The real selection
fails in 16 s naming both problems.

build-cube --label_dimension chooses the grouped columns, so a field a
selection needs can be added; it replaces the default seven rather than
extending them. The example selection no longer thresholds fields the default
cubes lack, and says at each site why and how to re-enable.

First full preview of the real blend: 13.7 s over 19 cubes, 3.04 T effective
tokens against a 400 B target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A sidecar row locates its document by (file_id, byte_offset, byte_len), and
file_id was a position in a file list re-derived from the filesystem at every
stage. That makes every recorded offset depend on the source tree never
changing, with nothing recording what it looked like.

An ongoing transfer then re-sharded four corpora after their sidecars were
built. Nemotron-CC went from 606 MB files to 137 MB files while keeping nearly
the same file count, so the count comparison that existed passed and the
offsets pointed past end of file. Eleven of nineteen datasets were unusable and
only one -- whose file count fell to zero -- failed loudly.

build-sidecar now records its file list in sidecar/<dataset>/_files.json,
written atomically since a sharded build has every task describing the same
list. apply resolves ids through that manifest instead of re-globbing, so an
added file cannot renumber anything, and refuses to run if a recorded file
changed size or vanished. Paths are relative, so moving or snapshotting a tree
stays valid.

New quality verify-sidecar seeks to recorded offsets and compares the document
found against the recorded text length, catching a file rewritten at the same
size. Rows at offset zero are skipped: the first document of any JSONL file
parses, so they succeed against a completely different file, which is how the
broken sidecars looked healthy.

build_sidecars no longer defaults its index root to the source tree.
SidecarBuilder writes a .idx beside each JSONL when given no index root, which
would modify a shared read-only corpus.

Adds a snapshot-based smoke test covering all four join-key kinds plus the
native-metrics-only path, so the full pipeline can be exercised in minutes
rather than 15 hours.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
--resume counted only the parts it re-joined, so a run that skipped everything
wrote a report saying 0 documents and 0.0 coverage. On the smoke run that
overwrote three datasets' genuine coverage with zeros, which reads as a failed
join rather than a skipped one.

Coverage describes the sidecar, not the run, so skipped parts now contribute
their existing labels to the totals and n_parts_resumed records how many were
not redone. Updates the resume test, whose assertion encoded the old
behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The smoke run compared preview estimates against a real packing run for the
first time. Document counts matched exactly; finewiki-de tokens were 10.7%
out. Two independent defects.

The sample was a prefix: _sample_documents took the first N documents of each
probe file. Deterministic, so it gave identical ratios across every seed, which
reads as stability rather than as a sample that never moves. On FineWiki the
first 2,000 documents give 3.531 bytes/token where the whole file gives 4.214.
I introduced this when bounding an earlier 30 TB read.

And one global ratio cannot describe a corpus: FineWiki runs from 3.571 for
documents under a kilobyte to 34.648 for the nine above 256 KB, which hold
5.7% of all bytes. The global estimator's error swung from -19.4% to +4.2%
depending on whether the sample caught them.

Now: offsets spread across each file, taking the document containing each
offset so selection is length-proportional (top stratum went from 2 samples
per 2,000 to 44); ratios measured per log-spaced size stratum and applied from
the byte length the sidecar already records; inverse-probability weights for
the corpus-wide fallback, since a plain sum ratio under this sampling came out
62% low; and each document measured on a <=64 KB slice from a random position,
because tokenizing the multi-megabyte documents in full took calibration past
10 minutes.

Worst error over 5 seeds against ground truth: 0.8%, down from 19.4%.

Pre-existing calibrations have no strata and fall back to the global ratio, so
they load but should be re-measured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Re-ran the smoke pipeline after the estimator fix. Total estimated vs packed
tokens now -0.02%, worst dataset -1.60%; finewiki-de was -10.72% before.
Document counts match exactly for all five datasets, which is the stronger
check since the filtered index names exactly the selected documents.

The blend loads through WeightedCombinedDataset with fractional repeat factors,
exercising the partial-pass permutation on real packed files for the first time,
and nothing was written under the source root.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The join built a dict of key to (part, row) over every document, a per-row dict
of labels, and a list comprehension per label column -- a handful of Python
objects per document, at 1.7 bn documents for Nemotron-CC. Each part is now
resolved with one index_in and one take per label column, and the key column
stays Arrow in the outer loop, where materialising it had been 1.7 bn Python
strings before any joining began.

Profiling what remained showed the next cost was not per-row work: 22 s of
reads and 14 s of a thousand separate is_in calls out of 47 s, because a 554 MB
split is spread over 1024 files of ~540 KB. The routing those buckets exist for
also decided nothing -- a batch's keys hash across every bucket, so all 1024
files were read anyway, after a blake2b call per key to choose them. Routing is
replaced by one pyarrow.dataset scan with the key filter pushed in, which keeps
memory bounded by matching rows rather than split size.

finewiki-en, 6.6 M documents: 88.7 s -> 42.1 s, 13.41 us -> 6.36 us per
document. Equivalence verified twice: 79,375,860 label values identical on the
benchmark and 1,441,584 across all four key kinds on the smoke blend, with
duplicate counts matching exactly.

This does not address the larger cost. The split is scanned once per batch, and
nemotron-cc has 85 batches over a 22 GB split -- about 1.9 TB, which is where
its twelve hours mostly went. The benchmark has one batch, so it measures the
overhead fixed here and none of the re-scanning. See CHANGELOG for the numbers
and the route to fixing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Matches the visual language of the existing training/pruning canvas: rounded
boxes, orange stage labels, owner badges, grouped containers, '=' underlined
section header.

Laid out as two converging lanes -- documents along the top, annotations below,
meeting at the join. A single row needs arrows that skip past boxes, and those
cross other boxes wherever they are routed; the first attempt had three such
crossings.

Includes the generator, so the layout can be extended as the pipeline changes,
and a check that no arrow segment passes through a box it does not terminate on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
 for
also decided nothing -- a batch's keys hash across every bucket, so all 1024
files were read anyway, after a blake2b call per key to choose them. Routing is
replaced by one pyarrow.dataset scan with the key filter pushed in, which keeps
memory bounded by matching rows rather than split size.

finewiki-en, 6.6 M documents: 88.7 s -> 42.1 s, 13.41 us -> 6.36 us per
document. Equivalence verified twice: 79,375,860 label values identical on the
benchmark and 1,441,584 across all four key kinds on the smoke blend, with
duplicate counts matching exactly.

This does not address the larger cost. The split is scanned once per batch, and
nemotron-cc has 85 batches over a 22 GB split -- about 1.9 TB, which is where
its twelve hours mostly went. The benchmark has one batch, so it measures the
overhead fixed here and none of the re-scanning. See CHANGELOG for the numbers
and the route to fixing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measured on nemotron-cc itself, after two proxy datasets gave per-document
costs 6x apart and a projection that exceeded the time actually observed with
slower code.

60 parts of the real nemotron-cc sidecar, 18,748,230 documents with real UUID
keys against the real 747 M-row split in 16,384 files: 772.6 s -> 505.0 s,
1.53x, with all 224,978,760 label values identical. That also settles whether
replacing bucket routing with a filtered dataset scan regressed this shape --
it does not, this is where the gain is.

But peak RSS doubled, 24.9 -> 51.9 GiB, against a 64 G request: 19% headroom on
a stage already OOM-killed once. Raised to 160 G.

Decomposition: one scan of the split costs 252.5 s, non-scan work 13.47
us/document, so the full dataset is roughly 6 h of scanning plus 6 h of
per-document work at today's 85 batches. Fewer batches is worth about 1.8x, not
the 10x my original estimate implied, and 6 h is the floor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A ratio gives every surviving document the same repeat factor, so a dataset
filtered to "content quality at least adequate" repeats its barely-adequate
documents as often as its excellent ones. `upsampling` replaces the scalar with
a curve whose factor rises with quality.

Method and functional form from Dolma 3 / Olmo 3 (arXiv:2512.13961), which
measured it against flat quality filtering on 1B models and found it better at
every matched repetition factor. Quality sits on a [0,1] axis where a bucket's
width is its share of the dataset's tokens; the factor is C*(x-a)**p above the
discard threshold, integral pinned to the token target, no bucket above
max_factor, monotone. Reproducing their published example returns exactly their
figure: bottom eight vigintiles dropped, top at 7.00x.

Two deliberate departures. Their family carries an extra exp(lam*(x-a)); fixing
lam=0 makes the solution unique rather than a feasible region and every integral
analytic. And the exponent is capped at 8, because steepest-admissible is right
only when the cap binds: when the target is a small fraction of the pool -- any
blend drawing far fewer tokens than it holds -- nothing violates the cap and the
budget collapses onto the top bucket, which is the hard top-k filtering the
curve exists to beat. The first attempt did exactly that, exponent 512 with one
surviving bucket.

apply writes one index tree per bucket, since the packer emits one file per
source file and differing factors only mean anything in separate indexes. Each
becomes a manifest row named <dataset>__<level> carrying source_dataset so the
registry lookup still resolves, and its own repeat factor in
WeightedCombinedDataset. The curve is re-solved from the exact counts apply
observes rather than the cube's interpolated ones.

Ordinal fields only for now; a numeric axis needs the cube's quantile edges in
materialisation. Not ablated -- the cluster was busy, so this is verified
against the paper's example and by unit tests, not a training run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The transfer renamed HPLTv4 to hpltv4 in all four languages, so the registry
resolved nothing for those datasets and apply failed on them. Not my change --
it came from the working tree while I was on the upsampling curves, and is
separated out so the feature commit stays about the feature.
Two ideas from a colleague's TokenFabric design. Most of that architecture
applies policy at training time and does not transfer to an offline pipeline,
but two of its principles do.

Repetition is per pass, and nothing was checking it. target_tokens was
documented as "only used to report the gap", and that is all it did. A ratio is
relative to one pass over the blend, so a run consuming more tokens than the
blend yields wraps and multiplies every factor. The curves added in the previous
change can assign 7x; two passes make that 14x, silently.

target_tokens now means what the run consumes, and max_total_exposure caps how
often anything may be seen -- a curved dataset uses its own max_factor instead.
preview reports it, apply refuses, --allow_overexposure overrides. Caps apply to
the requested factor even with no target declared, since a ratio of 9 against a
cap of 2 is a violation at any number of passes.

On the smoke blend, a declared 300 M run against its 151 M yield: climbmix-en /
high asked for 2.53x and would be seen 5.01x against its own 4x cap.

preview --explain attributes retention to individual predicates: what each
matches alone, its marginal effect with the others left in place, and a pairwise
overlap matrix. Milliseconds, since the per-cell weights are the whole
computation. It found two redundant predicates on the smoke blend immediately --
content_integrity on finepdfs-es (21 k marginal of 65 M) and educational_value
at_least basic on klettermix-de (matches 100.0%).

Two bugs found while building it: the overlap diagonal multiplied a predicate's
factor by itself, which squares a fractional survival rate and undercounts
(8.19 M read where 9.75 M matched); and _cube_weights tested the cumulative
exactness flag, so once any predicate was interpolated every later one was
reported as interpolated too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
target_tokens was already set but did nothing; it now means what the run
consumes. max_total_exposure is set to 4.0, above the largest ratio in the
selection (3.0), so it fires on an accident rather than on the intended design
-- a blend trimmed too far, or a ratio raised without re-checking the yield.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pointer resolution destroyed the pointers it could not resolve.
Nemotron-ClimbMix was moved to /data/annealing_unused, so resolution
returned nothing, wrote 225 M nulls over the pointers, logged it as INFO and
carried on -- surfacing two hours later as 0% coverage with the pointers gone.
It now refuses to write a part that resolved nothing.

The join scan was not bounded by matches. The comment claimed memory stays
bounded by matching rows; to_table buffers the whole scan. On HPLT, 65,536
bucket files and 3.76 bn rows, that hit 167 GB and was OOM-killed against a
160 G request even though only ~10 M rows can match. Now streamed via
Scanner.to_batches with bounded readahead.

The join report merge raced: non-atomic per-task writes let a sibling read a
half-written file, so finewiki-es died on JSONDecodeError after its own join
had already succeeded. Same fix as the bucket metadata, os.replace plus a
tolerant reader.

And the delivered Nemotron-CC changed shape: warc_record_id renamed to id, and
re-chunked from 1.70 bn documents of ~1 KB to 504 M of ~12 KB in the same
1.8 TB. Registry updated; 400/400 ids match in both subdirectories.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The streamed scan fixed the wrong layer. HPLT still exceeded 160 GiB, and the
measurements show peak memory tracks bucket-file count rather than data volume:
nemotron-cc at 16,384 files and 504 M documents peaked at 81.6 GB, while HPLT at
65,536 files and only 10.7 M documents was OOM-killed. Four times the files, one
forty-seventh of the data, and it is the one that dies -- a dataset holds a
fragment per file with that file's parquet metadata, and batching record batches
never touched that.

The split is now scanned in groups of SCAN_FILE_GROUP files. All three HPLT
splits completed in 42-45 min, and hplt-it, which had succeeded before, returns
identical coverage.

All 16 annotated datasets now join at 100%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pack_encoded_data rebuilds its components, tokenizer included, from the config
on every call -- 24.7 s measured on a compute node, against ~3 s of real work
for a Dolmino file. The blend renders 54,738 configs, 40,003 of them Dolmino,
so the CLI-per-file shape spends ~375 core-hours on startup for ~48 core-hours
of tokenising.

pack_many.py loads the tokenizer once per process (1.2 s, paid once) and
constructs a PackedDataGenerator per config, which is what pack_encoded_data
does internally. Slices are strided, since the config list is grouped by dataset
and contiguous slices would give one task all of Dolmino; a failing config logs
and continues; --skip_existing makes reruns resumable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The smoke checker assumes a handful of packed files and loads every document
index at once; at 54,738 files and 6.0 TB that does not hold.

scan_pbins.py reads every packed file's header and reports any with an empty or
unreadable data section. On the first full run it found exactly one bad file in
54,738: finewiki-it/000_00000.pbin, 151 MB on disk reporting data_len=0, a
leftover that pack_many.py --skip_existing had skipped because the file existed.
Existence taken for health -- the same proxy-instead-of-check mistake this
pipeline has now made in several places. --skip_existing now reads the header.
Had verification counted files instead of reading them, that dataset would have
trained 567 M tokens short.

verify_blend.py compares packed tokens against manifest estimates, packed
documents against documents selected, and file counts, reading one index at a
time.

Real blend after repacking: 18 datasets, all document counts exact,
1,637,755,654,948 packed tokens against 1,646,919,435,199 estimated, -0.56%
total, worst dataset -4.39%, nothing written into the source tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rrutmann and others added 4 commits August 25, 2026 10:22
Committed them straight from the scratch copies without reading the linter
output first. Multi-statement lines, an unused import and an over-long line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4_pack.sbatch now drives pack_many.py rather than one pack_encoded_data call
per config, which is what made packing 1 h 40 m instead of an estimated ten
hours of tokenizer startup. 5_verify.sbatch runs the three checks that each
caught something real: header scan, token and document counts against the
manifest, and loading the blend the way training will.

scan_pbins.py, verify_blend.py and load_blend.py take --work_dir instead of
hardcoded paths, so they work for any blend.

The README runbook now carries measured timings from the full run over
/data/annealing rather than estimates, and says which stage each check protects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each cost hours on the 20 TB blend and none had a test. They share a shape: a
stage fails, reports it somewhere quiet, and destroys or hides the evidence, so
the symptom shows up much later and further downstream.

- pointer resolution wrote nulls over the pointers it could not resolve, so a
  retry needed a full sidecar rebuild. The test asserts the pointers survive a
  failed resolution.
- the annotation scan held a fragment per bucket file, so a 65,536-file split
  with 10.7 M documents exceeded 160 GiB where a 16,384-file split with 504 M
  did not. The test varies SCAN_FILE_GROUP from 1 to 1024 and asserts the join
  result is identical.
- skip-if-exists treated a truncated .pbin as finished; one file in 54,738 was
  151 MB on disk reporting data_len=0. The test covers absent, truncated,
  garbage and healthy.

Self-contained fixtures rather than moving the shared ones out of
test_quality_pipeline.py, to avoid disturbing the 55 tests that use them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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