algo-v1-codepoint / docs /ARCHITECTURE.md
Miruamel's picture
Publish algo v1 (code-point tokenizer): 38.8M params, 16.4M tokens, loss 2.10
05f5056 verified
|
Raw History Blame Contribute Delete
11.4 kB
# Architecture
Five layers, strictly one-directional. Nothing below a layer imports from above it.
```
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ scripts/ β”‚ smoke.py, e2e.py
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ β”‚ β”‚
β”Œβ”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ rsi.py β”‚ β”‚ train.py β”‚ β”‚ eval.py β”‚
β”‚ accept/revert │◄──────────── loop, ckpt │───────────────►│ score, verify, β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ scores β”‚ resume, CLI β”‚ model+tokenizerβ”‚ gate, repair β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚ β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚ model.py β”‚β—„β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚ GQA, RoPE, RMSNorm, β”‚
β”‚ SwiGLU, MiniTransformerβ”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ data.py β”‚ config.py β”‚
β”‚ tokenizer, manifest, streams β”‚ frozen config objects β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
tools.py β€” orthogonal: called by eval (tool scoring) and by any agent runtime
```
`tools.py` is deliberately a leaf. The model never imports it; only the evaluation loop and external callers do. That keeps the sandbox auditable in isolation.
---
## Module contracts
### `config.py` β€” validation at the boundary
Four frozen dataclasses: `ModelConfig`, `TrainingConfig`, `EvalConfig`, `SafetyConfig`. Every one validates itself in `__post_init__`, so an invalid configuration cannot be constructed β€” it raises at construction, not deep inside a training step.
Cross-field invariants that are enforced (and would otherwise be silent bugs):
- `num_query_heads % num_kv_heads == 0` β€” GQA cannot broadcast otherwise.
- `d_model % num_query_heads == 0` β€” head_dim must be integral.
- `head_dim % 2 == 0` β€” RoPE rotates pairs of dimensions.
- `max_steps > warmup_steps` β€” the LR schedule would divide by zero.
- `precision == "fp32"` and `gradient_checkpointing is True` β€” the CPU contract is not negotiable.
- `schedule == "cosine"` β€” the only implemented schedule.
`ModelConfig` also owns the **parameter accounting** (`parameter_count_tied` / `parameter_count_untied`) as a closed-form formula, so the number in the README is derived, not measured. `scripts/smoke.py` asserts it against the constructed model.
### `model.py` β€” the network
`RMSNorm` β†’ `GroupedQueryAttention` β†’ `SwiGLU` β†’ `DecoderBlock` β†’ `MiniTransformer`.
`MiniTransformer.forward` returns logits directly: with tied embeddings the output projection is `hidden @ E.T` and `self.output_projection is None`. There is no bias anywhere in the projections.
Attention is computed in **query blocks of 128** so the score matrix is never materialised for the full sequence. This is the single most important memory decision in the model and the reason a 8,192-token context fits the inference budget.
Two guards matter for numerical safety:
- rows where **no** key is allowed (fully padded) would make `softmax` produce `NaN`; they are zeroed before the softmax and re-masked after it.
- masked scores are filled with `torch.finfo(dtype).min`, not `-inf`, so a `NaN` in one block cannot propagate.
`generate` is a straightforward autoregressive loop with a sliding window of `config.context_length`; it extends the attention mask by one position per step and stops as soon as any sequence in the batch emits `eos_id`. There is no KV cache β€” see [THEORY.md](THEORY.md#inference-memory) for what that means for the memory estimate.
`estimate_inference_memory_bytes()` is an **analytic** upper bound: weights + KV + hidden + one attention block + next-token logits. It is a budget-checking tool, not a measurement. See [OPERATIONS.md](OPERATIONS.md#verifying-the-memory-budget).
### `data.py` β€” bytes in, token streams out
`SimpleTokenizer` is deliberately a byte-level tokenizer: 6 reserved ids (`pad`, `eos`, `unk`, `tool`, `python`, `shell`) and one id per byte. It is not a good tokenizer β€” it is a *reproducible* one, which matters more at this stage, and it makes the whole pipeline Unicode-safe with no vocab file to ship.
`build_train_stream` produces fixed-length token windows from a list of examples, cycling the source, and accepts `start_token` so training can resume at an exact token offset. `build_eval_stream` pads to a fixed length instead.
`validate_manifest` enforces the provenance contract: a source may only be `enabled: true` if it has a 64-hex SHA-256 and a positive token budget; a disabled source must have `token_budget: 0`. URLs must be http(s).
### `train.py` β€” the loop
`train_model` owns the whole lifecycle:
1. move the model to the device, enable gradient checkpointing, wrap in `nn.DataParallel` when more than one CUDA device is visible;
2. build the optimizer;
3. optionally restore a checkpoint (weights, optimizer, step, token cursor, RNG states);
4. iterate the token stream, accumulating 8 sequences of 1024 tokens into one optimizer step;
5. checkpoint on the token grid, emit structured progress events, return `{step, tokens_seen, last_loss}`.
All telemetry is JSON Lines on **stderr** (`train_start`, `gpu_ready`, `train_progress`, `checkpoint_saved`, `train_done`), leaving stdout for the final result object. That split is what makes the run greppable in a Kaggle/Colab log.
### `eval.py` β€” score, verify, gate
`evaluate_model` moves the model to CPU, switches to eval mode, and per example:
- **math / coding** β€” generate, then run `VerifierRepairLoop(max_repairs=1)`: verify the answer, and on failure feed a feedback continuation back through the model once. A score counts only if the logits were finite *and* the verifier passed.
- **tool** β€” hand the parsed tool call to the executor; the score is the executor's `ok`.
The training/eval mode flag of the model is restored in a `finally` block, so an exception mid-eval cannot leave the model stuck in eval mode.
`strict_gate` returns an `EvalGateResult` that is truthy, so `if strict_gate(scores):` reads naturally.
### `rsi.py` β€” the policy
`rsi_accept` is a pure function over two `EvalScores`. It is the only place that decides whether a change is allowed to survive. `RSIController` adds apply/accept/revert state; `run_rsi_cycle` wraps a full experiment and **always reverts in a `finally`**, so a crashed experiment cannot leave a rejected candidate installed.
### `tools.py` β€” protocol and sandbox
`build_envelope` / `parse_envelope` define a canonical JSON envelope (`sort_keys`, compact separators) with protected `tool` / `request_id` fields, so a tool can never spoof the envelope's own metadata. `parse_tool_call` normalises a call into `{"kind", "payload"}` with `kind ∈ {python, shell}`.
Execution is described in [SECURITY.md](SECURITY.md).
---
## Checkpoint format
A single `torch.save` payload:
```python
{
"model": state_dict, # device-agnostic, always saved from the unwrapped module
"optimizer": optimizer.state_dict(),
"tokens_seen": int,
"step": int,
"rng_state": {"python": ..., "torch": ..., "cuda": [...]},
"extra": {"last_loss": float, "next_checkpoint_at": int},
"data_state": {"cursor": int}, # must equal tokens_seen, or loading raises
}
```
Properties that matter operationally:
- **Atomic.** Written to `.{name}.{pid}.tmp` and moved into place with `os.replace`, so a kill mid-write cannot corrupt the previous checkpoint.
- **Device-agnostic.** `load_checkpoint` always maps to CPU and transparently adds or strips the `module.` prefix that `nn.DataParallel` introduces, so a CPU checkpoint resumes on a GPU run and vice versa.
- **Verifiable cursor.** A checkpoint whose `data_state.cursor` disagrees with `tokens_seen` is rejected rather than silently resuming at the wrong position in the corpus.
## Data flow
```
manifest.json ──validate_manifest──► allowed sources
β”‚
examples ──encode──► token ids ──build_train_stream(start_token=…)──► [1024] windows
β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β–Ό
[1,1023] inputs + [1,1023] labels ──► forward ──► cross_entropy ──► backward
β”‚
accumulate until 8192 tokens ───
β–Ό
set LR ─► unscale grads ─► clip(1.0) ─► AdamW.step
β”‚
every 5M tokens ─► save_checkpoint (atomic)
```
## Extension points
| To add… | Do this | Not this |
| --- | --- | --- |
| a corpus | add a pinned entry to `data/manifest.json` with sha256 + budget, set `enabled: true` | dropping files into the repo |
| a tokenizer | implement `encode(text, add_eos=True) -> list[int]` and `decode(ids) -> str` | editing the training loop |
| a model change | subclass or edit `model.py`; re-run `scripts/smoke.py` so the parameter-count assertion still holds | bypassing `ModelConfig` |
| a new domain | add a branch in `evaluate_model` and a field on `EvalScores` + `EvalConfig` | adding an unvalidated score |
| a new tool | extend `parse_tool_call` + `execute_tool_call` and add it to the allowlists | calling `subprocess` directly from eval |
| an RSI experiment | call `run_rsi_cycle` with apply/evaluate/revert callbacks | mutating the baseline in place |