# Curriculum generation — `kibble tune`
`kibble tune` generates the Unsloth trainer's **sprint curriculum** — phased training datasets organized by source — from the built dataset. It maps each `[[tune.phase]]` to a set of sources, groups rows by source, and writes `sprint/<phase>/train.jsonl` datasets + a `config.yaml` that the trainer reads via `yaml.safe_load`.
## What it tunes on
Tune operates on the **built dataset's training split**: after `kibble build` writes `train.jsonl`, tune reads both `train.jsonl` (the messages) **and** `train.sources.jsonl` (the per-row provenance sidecar that `build` writes), groups rows by source, and assembles them into phases.
It requires:
- A **built dataset** (`train.jsonl` + `train.sources.jsonl`; run `kibble build` first)
- A **`[tune]` configuration** in `kibble.toml` defining the model, LoRA params, phases, and output location
Without either, `kibble tune` fails with a clear message (fail-soft).
## Provenance sidecar
Starting with the `build` refactor, each dataset split (train/valid/test) gets a **provenance sidecar** alongside its `.jsonl` file:
- **`train.jsonl`** — messages-only, one JSON object per line (unchanged)
- **`train.sources.jsonl`** — one source name per line, in the same order as `train.jsonl` rows
Each row's source is the `[source]` name or the derived name of the ingested document (e.g., `twitter-archive`, `readme`, `github-ml-papers`). The sidecar is deterministic (source order matches row order) and immutable after `build` writes it.
When `build` reruns with rebalancing enabled, the sidecar is rewritten to match the new row order after drops, so `tune` always reads matching row/source pairs.
## Configuration
```toml
[tune]
model = "unsloth/Meta-Llama-3.1-8B-Instruct-bnb-4bit"
r = 16 # LoRA rank (default)
lora_alpha = 16 # LoRA alpha (default)
lora_dropout = 0.05 # LoRA dropout (default)
max_seq_length = 4096 # context window (default)
learning_rate = 2.0e-4 # training LR (default)
smoke_rows = 20 # smoke-test dataset size (default)
out = "data/tune" # output directory (default)
[[tune.phase]]
name = "phase0"
sources = ["readme", "github-ml"]
max_steps = 100
# resume_from omitted → train this phase from the base model
[[tune.phase]]
name = "phase1"
sources = ["twitter-archive", "blog-posts"]
max_steps = 200
resume_from = "phase0"
[[tune.phase]]
name = "phase2"
sources = ["github-ml"]
max_steps = 150
resume_from = "phase1"
```
- `model` — HuggingFace model identifier (e.g., `unsloth/Meta-Llama-3.1-8B-Instruct-bnb-4bit`)
- `r` — LoRA rank (intrinsic dimensionality; default `16`)
- `lora_alpha` — LoRA scaling factor (default `16`)
- `lora_dropout` — dropout in LoRA modules (default `0.05`)
- `max_seq_length` — max tokens per training example (default `4096`)
- `learning_rate` — optimizer learning rate (default `2.0e-4`)
- `smoke_rows` — size of the smoke-test dataset for quick iteration (default `20`)
- `out` — directory where sprint curriculum and config are written (default `data/tune`)
- `[[tune.phase]]` — array of training phases:
- `name` — phase identifier (e.g., `phase0`, `phase1`)
- `sources` — list of source names to include in this phase (empty list = skip phase)
- `max_steps` — maximum training steps for this phase (passed to the trainer)
- `resume_from` — optional phase name to resume adapter weights from; **omit the key** to train this phase from the base model (TOML has no `null` literal — leave `resume_from` out rather than setting it to `null`)
## Output layout
Tune writes to `<out>/` (default `data/tune/`):
```
data/tune/
sprint/
phase0/
train.jsonl # rows from sources ["readme", "github-ml"]
phase1/
train.jsonl # rows from sources ["twitter-archive", "blog-posts"]
phase2/
train.jsonl # rows from sources ["github-ml"]
phase0-smoke/
train.jsonl # first smoke_rows (20) of the FIRST non-empty phase (dir always named phase0-smoke)
config.yaml # trainer config: JSON-formatted (valid YAML)
```
- **`sprint/<phase>/train.jsonl`** — one per non-empty phase, messages-only (verbatim from the built dataset, no re-encoding)
- **`phase0-smoke/train.jsonl`** — smoke test: first `smoke_rows` of the first non-empty phase (for quick trainer iteration)
- **`config.yaml`** — trainer configuration written as **pretty JSON** (indented, sorted keys). Valid YAML syntax for the trainer's `yaml.safe_load` call. Contains model name, LoRA params, max sequence length, batch size, gradient accumulation, learning rate, and the phase specs (name, path, output dir, max steps, resume-from).
## The pipeline
```
kibble build
↓
train.jsonl + train.sources.jsonl
↓
kibble tune
↓
sprint/<phase>/train.jsonl + config.yaml
↓
kibble pack
↓
Kaggle dataset bundle
↓
Upload to Kaggle
↓
Run trainer (Unsloth / MLX)
↓
Adapter weights + eval
```
- **`build`** reads configured sources and produces `train.jsonl` + `train.sources.jsonl` (per-row source attribution)
- **`tune`** groups rows by source into phases and writes the sprint curriculum
- **`pack`** bundles the curriculum and training scripts for Kaggle
- **Training** (external) reads `sprint/<phase>/train.jsonl` and `config.yaml`, runs the phased curriculum, and outputs adapter weights
## Validation and fail-soft
Tune performs several validation checks and handles errors gracefully:
- **No `[tune]` table:** error with a message pointing to `docs/TUNE.md`
- **Missing `train.jsonl`:** error message suggests running `kibble build` first
- **Missing `train.sources.jsonl`:** error message suggests running `kibble build` first
- **Row/source count mismatch:** error message suggests re-running `kibble build`
- **Unknown source:** any source name referenced by a `[[tune.phase]]` that never appears in `train.sources.jsonl` prints a warning to stderr (`kibble: tune — source '<src>' has no rows in the dataset`). This fires per missing source name, independent of whether its phase ends up empty.
- **Empty phase** (none of its sources contribute rows): skipped — no `sprint/<phase>/train.jsonl` is written for it, and it is omitted from `config.yaml`.
- **No matching sources:** error if *every* phase is empty (no rows matched any source).
All validation checks (missing `[tune]`, missing files, count mismatch, no-match) run **before** any files are written, so those failure modes leave nothing on disk. The writes themselves are a plain sequence of `create_dir_all`/`write` calls (not transactional): an I/O error midway (e.g. disk full) can leave earlier phase directories already written before the command errors out.
## Follow-ups
- **Phase B (deferred):** Training orchestration — automatically run the trainer against the curriculum on Kaggle/MLX, poll for completion, and return adapter weights.
- **Phase C (deferred):** Adapter-back + eval-after-train — pull trained adapters back to the repo, run eval against the fine-tuned model, and report quality metrics.
See `.superpowers/sdd/task-*.md` for the roadmap.