flowchart LR
subgraph data_pipeline[" "]
direction TB
raw_text["Text"] --> examples["Examples"] --> token_ids["Token IDs"]
end
subgraph evaluation[" "]
direction TB
evidence["Evidence"]
checkpoint["Checkpoint"]
end
token_ids --> language_model["Language<br/>model"] --> checkpoint
checkpoint -->|evaluation| evidence
classDef base fill:#ffffff,stroke:#1f2937,stroke-width:2px,color:#111827
classDef model fill:#fed7aa,stroke:#c2410c,stroke-width:3px,color:#111827,font-size:20px,font-weight:bold
classDef state fill:#fef3c7,stroke:#92400e,stroke-width:2px,color:#111827
classDef result fill:#dcfce7,stroke:#166534,stroke-width:2px,color:#111827
class raw_text,examples,token_ids base
class language_model model
class checkpoint state
class evidence result
Corpus and Reproducible Data Systems
A language model inherits the boundaries of its corpus. Before implementing a tokenizer or a neural network, make those boundaries explicit: record where each document or generated proof family came from, normalize it by a named rule, split by stable structural identities, and retain enough metadata to reproduce every later claim. This chapter establishes the corpus contract that the remaining chapters consume.
The first examples are deliberately small. They are not miniature pretraining corpora; they are test fixtures in which provenance, duplicate content, leakage, structural identity, and random state can be inspected by hand. The same invariants will survive when the fixtures become the mixed natural-language, mathematical, and verifier-generated proof corpus used by ProofLM.
Data and model lifecycle
The course follows one model through a sequence of transformations:
At each arrow, three questions keep the implementation honest. What information is added? Which behavior is encouraged? Which new failure modes become possible? A manifest adds provenance but cannot establish that a license is valid. A tokenizer adds a discrete representation but can create long sequences or awkward unknown-token behavior. Training adds parameters that fit the objective, but the objective is still only a proxy for useful language behavior.
The division between pretraining, posttraining, and specialized fine-tuning is therefore a division of data and objectives, not a claim that the parameter state is reset between stages. Pretraining learns from next-token continuation on broad text. Posttraining changes interactive behavior through demonstrations, preferences, rewards, or tool traces. Specialization narrows the distribution again. Every stage must be compared with a held-out general suite because later updates can alter earlier capabilities.
Data splits
Let D = \{(x_i, m_i)\}_{i=1}^n be documents paired with metadata m_i, and let \ell_\theta(x) be a loss computed by a model. Training estimates an empirical risk,
\widehat{R}_{\mathrm{train}}(\theta) = \frac{1}{|D_{\mathrm{train}}|} \sum_{x \in D_{\mathrm{train}}} \ell_\theta(x),
while the quantity of interest is performance on a separate distribution P_{\mathrm{eval}}:
R_{\mathrm{eval}}(\theta) = \mathbb{E}_{x \sim P_{\mathrm{eval}}}[\ell_\theta(x)].
A test example copied into training makes the empirical training and evaluation samples dependent. In the extreme case, a model can return a memorized continuation with low loss without learning a rule that transfers. The split is therefore not bookkeeping around the experiment; it determines what the experiment can establish.
A practical leakage predicate operates on stable identities. If h(x) is a canonical content hash, a basic audit asks whether
\{h(x):x\in D_{\mathrm{train}}\} \cap \{h(x):x\in D_{\mathrm{test}}\} = \varnothing.
Document identity and content identity answer different questions. A document-level split prevents chunks from one document crossing the boundary. Hashing additionally catches duplicate documents that were assigned different identifiers.
Dataset manifests
The first fixture stays small enough to inspect by hand, but its reusable invariants now live in projects/proof-lm/. The package supplies immutable document records, canonical normalization and hashing, generated proof examples, independent verification, checked countermodels, structural split assignment, and dataset-manifest construction. The notebook imports those functions so that the later tokenizer and trainer consume the same artifact rather than a parallel notebook-only implementation.
from collections import defaultdict
import json
import re
import numpy as np
from proof_lm.data import (
Document,
build_dataset_manifest,
build_mixed_smoke_corpus,
content_hash,
manifest_record,
normalize_text,
split_examples,
)
from proof_lm.logic import generate_examples, verify_proof
SEED = 17
documents = [
Document(
"d00",
"synthetic",
"course-fixture-v1",
"A manifest records provenance. A split is a contract. Hashes make changes visible.",
),
Document(
"d01",
"synthetic",
"course-fixture-v1",
"A tokenizer maps text to symbols. Shorter sequences reduce compute. Boundaries affect behavior.",
),
# Different surface form, identical canonical content: a duplicate to find.
Document(
"d02",
"synthetic",
"course-fixture-v1",
"A MANIFEST records provenance. A split is a contract. Hashes make changes visible.",
),
Document(
"d03",
"synthetic",
"course-fixture-v1",
"Validation estimates transfer. Test data should stay unseen. Every claim needs a split.",
),
Document(
"d04",
"synthetic",
"course-fixture-v1",
"A checkpoint stores parameters. A seed fixes a trajectory. A log records the budget.",
),
]
# The duplicate report is useful even before a split has been selected.
hash_groups = defaultdict(list)
for document in documents:
hash_groups[content_hash(document.text)].append(document.document_id)
duplicates = [ids for ids in hash_groups.values() if len(ids) > 1]
print("duplicate groups:", duplicates)
print(json.dumps(manifest_record(documents[0], "train"), indent=2, sort_keys=True))
assert duplicates == [["d00", "d02"]]
# The reusable proof generator supplies the verified formal slice of the corpus.
proof_examples = generate_examples(count=12, seed=SEED)
proof_splits = split_examples(proof_examples, seed=SEED)
proof_dataset, proof_manifest = build_dataset_manifest(proof_examples, proof_splits)
positive_examples = [example for example in proof_examples if example.kind == "positive"]
negative_examples = [example for example in proof_examples if example.kind == "negative"]
print("verified positives:", len(positive_examples))
print("controlled negatives:", len(negative_examples))
print("proof manifest:", proof_dataset.dataset_manifest_id)
assert all(verify_proof(example.proof).valid for example in positive_examples)
assert all(not verify_proof(example.proof).valid for example in negative_examples)
assert proof_dataset.total_examples == len(proof_manifest)
# One manifest now joins language, mathematics, and generated proof records.
mixed_dataset, mixed_manifest = build_mixed_smoke_corpus(seed=SEED, proof_count=12)
source_counts = defaultdict(int)
for row in mixed_manifest:
source_counts[row["source_kind"]] += 1
print("mixed manifest:", mixed_dataset.dataset_manifest_id)
print("mixed source counts:", dict(sorted(source_counts.items())))
assert set(source_counts) == {"language", "mathematics", "generated-proof"}
assert mixed_dataset.total_examples == len(mixed_manifest)duplicate groups: [['d00', 'd02']]
{
"characters": 82,
"document_id": "d00",
"provenance": "course-fixture-v1",
"sha256": "92335f11bcab276e009891f2a2f33f70464d6915b352152051c84aca164a2f18",
"size_bytes": 82,
"source": "synthetic",
"split": "train",
"whitespace_tokens": 13
}
verified positives: 12
controlled negatives: 24
proof manifest: dataset-prooflm-smoke-v1
mixed manifest: dataset-prooflm-mixed-smoke-v1
mixed source counts: {'generated-proof': 36, 'language': 2, 'mathematics': 2}
The duplicate report catches d00 and d02 even though their raw strings differ. The hash is short enough to scan in a report only because the full SHA-256 value is retained in the manifest; truncation would make collisions easier. A production pipeline would add license, collection time, preprocessing version, and the hash of the exact input artifact.
Identity-based splits
A deterministic split needs two ingredients: a stable unit of assignment and a recorded seed. Assigning chunks independently is tempting because it balances row counts, but it permits near-duplicate context from one document to appear on both sides. Assign complete documents first, then create windows inside each split.
def document_split_ids(documents, seed=SEED, train_fraction=0.6, valid_fraction=0.2):
ids = np.array(sorted(document.document_id for document in documents))
order = np.random.default_rng(seed).permutation(len(ids))
shuffled = ids[order]
n_train = int(train_fraction * len(ids))
n_valid = int(valid_fraction * len(ids))
return {
"train": set(shuffled[:n_train]),
"validation": set(shuffled[n_train:n_train + n_valid]),
"test": set(shuffled[n_train + n_valid:]),
}
def chunks_for(document: Document):
sentences = [part.strip() for part in re.split(r"(?<=[.!?])\s+", document.text) if part.strip()]
return [{"document_id": document.document_id, "chunk_id": i, "text": sentence}
for i, sentence in enumerate(sentences)]
def ids_in(rows):
return {row["document_id"] for row in rows}
doc_splits = document_split_ids(documents)
row_chunks = [chunk for document in documents for chunk in chunks_for(document)]
# A deliberately naive row split: alternating rows guarantees a visible boundary violation.
row_train, row_validation = row_chunks[::2], row_chunks[1::2]
doc_train = [chunk for chunk in row_chunks if chunk["document_id"] in doc_splits["train"]]
doc_validation = [chunk for chunk in row_chunks if chunk["document_id"] in doc_splits["validation"]]
row_overlap = ids_in(row_train) & ids_in(row_validation)
doc_overlap = ids_in(doc_train) & ids_in(doc_validation)
manifest = [
manifest_record(document, next(split for split, ids in doc_splits.items()
if document.document_id in ids))
for document in documents
]
print("document split sizes:", {name: len(ids) for name, ids in doc_splits.items()})
print("naive row split document overlap:", sorted(row_overlap))
print("document split overlap:", sorted(doc_overlap))
print("manifest rows:", len(manifest))
assert row_overlap
assert not doc_overlap
assert sum(len(ids) for ids in doc_splits.values()) == len(documents)document split sizes: {'train': 3, 'validation': 1, 'test': 1}
naive row split document overlap: ['d00', 'd01', 'd02', 'd03', 'd04']
document split overlap: []
manifest rows: 5
The row split leaks document identity by construction, while the document split does not. Notice the remaining caveat: d00 and d02 can still land in different document partitions because they have different identifiers. The duplicate report must be applied before assignment, or duplicate groups must be treated as one split unit.
Both split functions are deterministic under a fixed seed; only one keeps every chunk of a document on the same side. Determinism makes a run repeatable, while the boundary decides what the measured loss estimates. The hash-overlap audit in the next section is the check that distinguishes the two.
Contamination and leakage
The most useful audit is one that can fail on purpose. Compare a clean document-level boundary with a contaminated boundary in which one validation string is copied into training. Keep the evaluator and all other settings fixed. If the metric improves only after contamination, the improvement is evidence about leakage, not learning.
For the running fixture, the checks are structural rather than statistical: count shared document IDs and shared canonical hashes. On a real corpus, add near-duplicate search, source-level overlap, temporal overlap, length distributions, language or domain proportions, and manual examples selected from both boundaries.
def hash_overlap(left, right):
left_hashes = {content_hash(row["text"]) for row in left}
right_hashes = {content_hash(row["text"]) for row in right}
return left_hashes & right_hashes
clean_train = [
{"text": "the model predicts the next token"},
{"text": "a checkpoint records the training state"},
]
clean_test = [
{"text": "the evaluator measures held-out behavior"},
{"text": "a split protects the strength of the claim"},
]
contaminated_train = clean_train + [clean_test[0]]
print("clean hash overlap:", len(hash_overlap(clean_train, clean_test)))
print("contaminated hash overlap:", len(hash_overlap(contaminated_train, clean_test)))
assert not hash_overlap(clean_train, clean_test)
assert hash_overlap(contaminated_train, clean_test)clean hash overlap: 0
contaminated hash overlap: 1
The contaminated split is not a toy version of a harmless mistake. It changes the estimand: the measured test loss now partly asks whether the pipeline can recognize data it has already seen. A lower number may still be useful for a memorization study, but it cannot be reported as held-out generalization. Keep contaminated fixtures in tests and name them as such.
Data reliability
The manifest fields defined above are what make a later result interpretable. Suppose a later chapter reports that validation loss fell from 2.4 to 1.9. The first question to ask is whether any validation document shares a canonical hash with a training document. If the hashes and split identities were never recorded, the question has no answer, and the 1.9 cannot be classified as a transfer result, a memorization result, or a preprocessing artifact. The split audit is therefore part of the model artifact rather than a one-time check: every later chapter inherits this boundary.
The same reasoning produces the reporting habit used for the rest of the course. Evaluate on three inputs and report the scores side by side: training-like inputs estimate fit, held-out inputs estimate transfer, and shifted inputs estimate dependence on surface features of the training distribution. A large gap between the first two scores is evidence of overfitting or leakage, and the next action is a hash-overlap audit of the boundary rather than an architecture change. A large gap between the last two is evidence of shortcut features, which Chapter 13 makes directly measurable.
Structural proof splits
The package manifest records more than a row count. Each generated example carries a source revision, license, generator version, normalization version, token budget, theorem family, proof shape, depth, variable family, paraphrase template, perturbation, tool schema, and structural key. The audit below verifies that no structural group crosses a boundary and that every positive and negative fixture is represented exactly once.
split_by_id = {}
for split, ids in proof_splits.items():
for example_id in ids:
split_by_id[example_id] = split
keys_by_split = {}
for example in proof_examples:
key = example.structural_key
keys_by_split.setdefault(key, set()).add(split_by_id[example.example_id])
mixed_hash_splits = {}
mixed_structural_splits = {}
for row in mixed_manifest:
split = row["split"]
mixed_hash_splits.setdefault(row["sha256"], set()).add(split)
mixed_structural_splits.setdefault(row["structural_key"], set()).add(split)
split_sizes = {name: len(ids) for name, ids in proof_splits.items()}
mixed_sizes = {
name: part.examples for name, part in mixed_dataset.splits.items()
}
manifest_fields = sorted(proof_manifest[0])
print("proof split sizes:", split_sizes)
print("mixed split sizes:", mixed_sizes)
print("structural groups:", len(keys_by_split))
print("manifest field count:", len(manifest_fields))
print("manifest fields:")
for start in range(0, len(manifest_fields), 4):
print(" ", ", ".join(manifest_fields[start : start + 4]))
assert all(len(split_names) == 1 for split_names in keys_by_split.values())
assert set(split_by_id) == {example.example_id for example in proof_examples}
assert all(
row["source_revision"] == "generated-proof-fixture-v1" for row in proof_manifest
)
assert all(row["generator_version"] == "proof-generator-v1" for row in proof_manifest)
assert all(len(splits) == 1 for splits in mixed_hash_splits.values())
assert all(len(splits) == 1 for splits in mixed_structural_splits.values())
assert sum(mixed_sizes.values()) == mixed_dataset.total_examplesproof split sizes: {'train': 21, 'validation': 7, 'test': 8}
mixed split sizes: {'train': 23, 'validation': 8, 'test': 9}
structural groups: 30
manifest field count: 19
manifest fields:
characters, example_id, generator_version, kind
license, normalization_version, paraphrase_template, perturbation
proof_depth, proof_shape, sha256, source_revision
split, structural_key, theorem_family, token_budget
tool_schema, variable_family, verified
The formal fixture has a stronger boundary than the document toy. A structural split assigns every generated theorem family, proof shape, variable family, paraphrase template, perturbation, and tool schema to one partition. Inspect the keys directly before treating the manifest as training data.
Summary
- A language-model experiment is a chain of data and parameter transformations; every arrow introduces a new assumption.
- Empirical risk is only evidence about a separate evaluation distribution when the boundary is meaningful.
- Canonical hashes catch duplicate surface forms; document-level assignment prevents chunk leakage.
- Seeds, manifests, split identities, and preprocessing versions make a result reproducible and auditable.
Chapter 02 turns the clean text boundary into token IDs and attention-ready batches. It keeps the same discipline: define the representation, test its invariants, and measure what changed.
Exercises
Use the exercises to separate a reproducible pipeline from a merely repeatable script. Solutions are hidden in the notebook source and are available through the course tooling when needed.
[P1.1] Split audit
Split audit. Explain why assigning chunks independently can make validation loss optimistic even when the random seed is fixed. Give one invariant for document identity and one for canonical content hashes.
[P1.2] Canonical duplicate groups
Manifest implementation. Complete the starter so it returns the number of canonical duplicate groups in a list of documents. Treat whitespace and case differences as identical.
def duplicate_group_count(texts):
# Return the number of hash groups containing at least two texts.
pass[P1.3]
Source-preserving mixed manifests. build_mixed_smoke_corpus returns one dataset manifest whose rows come from language, mathematics, and generated-proof sources. Implement source_counts(records) so it returns a dictionary keyed by source_kind, then explain why source kind belongs on each row even when all rows share one dataset manifest ID.
def source_counts(records):
# Count rows by their source_kind field.
pass