Skip to main content

apr_cli/commands/
pretrain.rs

1//! `apr pretrain` — pretraining loop driver for SHIP-TWO-001 MODEL-2.
2//!
3//! Wires `entrenar::train::pretrain::PretrainLoop` into the CLI. The
4//! loop shape is enforced by `contracts/training-loop-pretrain-v1.yaml`
5//! — specifically GATE-TRAIN-005 (divergence), GATE-TRAIN-007 (NaN),
6//! and GATE-TRAIN-008 (throughput range).
7//!
8//! For MODEL-2 specifically, the 370M model forward pass is still a
9//! scaffold (see `crates/aprender-train/src/models/llama_370m.rs`),
10//! so this command runs in **synthetic** mode by default: it drives
11//! the loop with a deterministic decreasing-loss step function so the
12//! contract gates are exercised end-to-end even before the 370M
13//! compute path is wired.
14
15use crate::error::{CliError, Result};
16use crate::output;
17use clap::ValueEnum;
18use colored::Colorize;
19use entrenar::models::llama_370m::{
20    assert_tokenizer_vocab_matches_model, assert_tokenizer_vocab_within_model_bound,
21    Llama370MConfig,
22};
23use entrenar::train::device::{resolve_device, Device};
24use entrenar::train::pretrain::{
25    CheckpointFn, LinearDecaySynthetic, PretrainAbort, PretrainConfig, PretrainLoop, RunStatus,
26    ScriptedVal, StepFn, TrainingRegime, ValFn,
27};
28use entrenar::train::pretrain_real::{
29    build_shared_trainer, build_shared_trainer_with_init, AprCheckpointFn, RealStepFn, RealValFn,
30};
31use entrenar::train::shard_reader::ShardBatchIter;
32use entrenar::train::transformer_trainer::LMBatch;
33use entrenar::transformer::TransformerConfig;
34use std::path::Path;
35
36/// Number of LMBatches pulled off the head of the shard stream and
37/// reserved as the held-out validation set.
38///
39/// 2026-04-26: bumped from 2 → 16 to reduce val_loss measurement
40/// noise on from-scratch runs. With batch=16 seq=512, the prior
41/// 2-batch held-out covered just 16,384 tokens — single-batch
42/// fluctuation was ~0.04 in val_loss, which is at the same scale
43/// as epoch-over-epoch improvement signal during early training.
44/// A 50K-step run early-stopped at epoch 5/24 even though
45/// train_loss was monotonically decreasing (10.01 → 9.54). With 16
46/// held-out batches (131K tokens), val_loss noise floor drops
47/// proportionally to ~0.01, restoring early-stop signal-to-noise.
48const HELD_OUT_BATCHES: usize = 16;
49
50/// Drift-prevention constant pinned by `apr-pretrain-arch-polymorphic-v1`
51/// v1.7.0 §FALSIFY-APR-PRETRAIN-INIT-CUDA-001.
52///
53/// Pre-§50.4-step-5f.5 (this constant's first incarnation, v1.4.0..v1.6.0):
54/// the fail-fast error returned when `--init <PATH>` AND `--device cuda`
55/// were combined and the CUDA wireup did not exist. The const was the
56/// drift-prevention surface — a unit test verified the citation, the
57/// "not yet wired" phrase, and the 5f.5 reference all appeared.
58///
59/// Post-5f.5 (this PR — `apr-pretrain-arch-polymorphic-v1` v1.7.0): the
60/// CUDA wireup landed via `entrenar::train::pretrain_real_cuda::
61/// build_shared_cuda_trainer_with_init` (symmetric to the CPU
62/// `build_shared_trainer_with_init`). The const is RETAINED but its
63/// payload is repurposed as a drift-prevention sentinel: if a future
64/// refactor accidentally re-introduces a fail-fast on the CUDA + --init
65/// path, the test that pins this string will fail-fast and surface the
66/// regression. The string itself is no longer emitted by any code path
67/// in `drive_real`; it survives only to anchor the contract obligation.
68pub(crate) const FALSIFY_APR_PRETRAIN_INIT_CUDA_001_MSG: &str =
69    "FALSIFY-APR-PRETRAIN-INIT-CUDA-001: --init is wired for --device cuda \
70     via build_shared_cuda_trainer_with_init (5f.5 SHIPPED); operator can pass \
71     --init <PATH> --device cuda for end-to-end GPU fine-tune dispatch.";
72
73/// CLI selector bound to training-loop-pretrain-v1 §hyperparameter_defaults.
74/// Atomically flips the `(regime, lr_max, warmup_steps, target_val_loss)`
75/// 4-tuple per INV-TRAIN-009. Explicit `--lr` / `--warmup-steps` /
76/// `--target-val-loss` still win over the table row.
77#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
78pub enum PretrainMode {
79    /// Post-divergence MODEL-1 remedy defaults (lr=5e-5, warmup=100, target=2.2).
80    Finetune,
81    /// 370M cold-start defaults (lr=3e-4, warmup=1000, target=3.0).
82    FromScratch,
83}
84
85/// Resolved HP tuple from the contract's `hyperparameter_defaults` table.
86/// Inputs are CLI-provided overrides (`None` means "inherit mode default").
87/// Output binds INV-TRAIN-009: regime ALWAYS matches `mode`, and any field
88/// the operator set explicitly passes through unchanged.
89#[derive(Clone, Debug, PartialEq)]
90pub(crate) struct ResolvedHp {
91    pub regime: TrainingRegime,
92    pub lr_max: f32,
93    pub warmup_steps: usize,
94    pub target_val_loss: f32,
95}
96
97/// SPEC §82 P0-H: derive APR checkpoint `general.name` and `architecture`
98/// metadata from the `--init` model's TransformerConfig. Without this, the
99/// trainer hardcoded `("llama-370m-pretrain", "LlamaForCausalLM")` even when
100/// fine-tuning a Qwen2 model — which silently produced GGUF exports that
101/// llama.cpp could not load because the 72 Qwen2 bias tensors (q_proj_bias,
102/// k_proj_bias, v_proj_bias per layer × 24 layers) leaked through the
103/// llama-family GGUF mapper as unrecognized passthrough names. The fix
104/// stamps `Qwen2ForCausalLM` so the qwen2 family mapper handles biases
105/// correctly.
106///
107/// Falls back to the pre-§82 defaults when `--init` is not provided (a
108/// from-scratch llama-370m pretrain).
109fn checkpoint_name_and_arch(init_arch: Option<&TransformerConfig>) -> (String, String) {
110    match init_arch {
111        Some(arch) => {
112            let hf_arch = arch
113                .hf_architecture
114                .clone()
115                .unwrap_or_else(|| "LlamaForCausalLM".to_string());
116            // Use the lowercase hf_model_type for the name suffix when
117            // available (e.g. "qwen2-pretrain"), else fall back to a
118            // generic name.
119            let name = arch
120                .hf_model_type
121                .as_deref()
122                .map_or_else(|| "model-pretrain".to_string(), |t| format!("{t}-pretrain"));
123            (name, hf_arch)
124        }
125        None => (
126            "llama-370m-pretrain".to_string(),
127            "LlamaForCausalLM".to_string(),
128        ),
129    }
130}
131
132/// SPEC §82 P1-A: Estimate transformer parameter count from arch dims.
133///
134/// Formula (decoder-only, tied or untied embedding):
135///   N ≈ vocab × hidden                              (embedding)
136///     + L × (4·hidden² + 3·hidden·intermediate)     (per-layer attn + ffn)
137///     + hidden                                       (final norm)
138///
139/// Embedding is counted once (assumes tied lm_head; for untied add a 2nd
140/// `vocab × hidden`). This is a coarse estimate suitable for Chinchilla
141/// scaling sanity checks, not a precise param report — for that, use
142/// `apr inspect --json | jq .parameters`.
143fn estimate_param_count(arch: &TransformerConfig) -> u64 {
144    let vocab = arch.vocab_size as u64;
145    let hidden = arch.hidden_size as u64;
146    let inter = arch.intermediate_size as u64;
147    let layers = arch.num_hidden_layers as u64;
148    let embed = vocab.saturating_mul(hidden);
149    let attn_per_layer = 4u64.saturating_mul(hidden).saturating_mul(hidden);
150    let ffn_per_layer = 3u64.saturating_mul(hidden).saturating_mul(inter);
151    let per_layer = attn_per_layer.saturating_add(ffn_per_layer);
152    let layer_total = layers.saturating_mul(per_layer);
153    embed.saturating_add(layer_total).saturating_add(hidden)
154}
155
156pub(crate) fn mode_defaults(
157    mode: PretrainMode,
158    vocab_size: u32,
159    lr_override: Option<f32>,
160    warmup_override: Option<usize>,
161    target_override: Option<f32>,
162) -> ResolvedHp {
163    let (regime, lr_def, warmup_def, target_def) = match mode {
164        PretrainMode::Finetune => (TrainingRegime::Finetune, 5.0e-5, 100, 2.2),
165        PretrainMode::FromScratch => (
166            TrainingRegime::FromScratch { vocab_size },
167            3.0e-4,
168            1000,
169            3.0,
170        ),
171    };
172    ResolvedHp {
173        regime,
174        lr_max: lr_override.unwrap_or(lr_def),
175        warmup_steps: warmup_override.unwrap_or(warmup_def),
176        target_val_loss: target_override.unwrap_or(target_def),
177    }
178}
179
180/// Execute `apr pretrain`.
181#[allow(clippy::too_many_arguments)]
182pub(crate) fn run(
183    dataset: &Path,
184    tokenizer: &Path,
185    run_dir: &Path,
186    mode: PretrainMode,
187    lr: Option<f32>,
188    num_steps: usize,
189    warmup_steps: Option<usize>,
190    batch_size: usize,
191    seq_length: usize,
192    steps_per_epoch: usize,
193    seed: u64,
194    target_val_loss: Option<f32>,
195    vocab_size: u32,
196    synthetic: bool,
197    device: &str,
198    init: Option<&Path>,
199    force_under_provisioned: bool,
200    val_shard: Option<&Path>,
201    json_output: bool,
202) -> Result<()> {
203    // Contract gpu-training-backend-v1 INV-GPUTRAIN-001 / GATE-GPUTRAIN-002:
204    // parse --device BEFORE any trainer allocation so an invalid spec
205    // or an explicit `cuda` on a CPU-only host fails fast with a clear
206    // diagnostic. Synthetic drive still honours --device (for parity
207    // with real compute) but the stub error surface is identical.
208    let resolved_device =
209        resolve_device(device).map_err(|e| CliError::ValidationFailed(e.to_string()))?;
210
211    // Contract apr-pretrain-from-init-v1 §init_load_semantics + §50.4 step 5f.4:
212    // when --init is present, (1) validate magic bytes, (2) extract
213    // TransformerConfig from the APR header metadata, (3) propagate the
214    // extracted arch through preflight + trainer construction.
215    // Per `apr-pretrain-arch-polymorphic-v1` §arch_extraction_signature,
216    // missing or unreadable architecture metadata is FAIL-FAST not silent-fallback.
217    let init_arch: Option<TransformerConfig> = if let Some(init_path) = init {
218        validate_init_apr_path(init_path)?;
219        Some(
220            crate::commands::model_config::read_apr_architecture(init_path).ok_or_else(|| {
221                CliError::ValidationFailed(format!(
222                    "FALSIFY-APR-PRETRAIN-INIT-005: --init APR file at {} has missing or invalid \
223                     architecture metadata (hidden_size, num_heads, num_layers, vocab_size, etc). \
224                     Cannot extract TransformerConfig per apr-pretrain-arch-polymorphic-v1 \
225                     §arch_extraction_signature.",
226                    init_path.display()
227                ))
228            })?,
229        )
230    } else {
231        None
232    };
233
234    let hp = mode_defaults(mode, vocab_size, lr, warmup_steps, target_val_loss);
235
236    // SPEC §82 P1-A + SPEC §83 P0-J: Chinchilla compute-optimal gate
237    // (Hoffmann et al. 2022, arXiv:2203.15556). Compute-optimal pretraining
238    // requires train tokens D ≈ 20·N where N is the parameter count.
239    //
240    // P0-J upgrade (post-audit, 2026-05-16, audit Rec #2): D/N < 10× is
241    // now a HARD BLOCKER (fail-fast) unless `--force-under-provisioned`
242    // is passed. 10× ≤ D/N < 20× is a strong warning. Triggered only on
243    // `--init` runs where arch dims allow N estimation; from-scratch
244    // runs are exempt.
245    //
246    // Audit motivation: §82 P2-A's 0.04× ratio + repetitive token
247    // gibberish at val_loss=4.71 (Holtzman et al. 2019 degeneration)
248    // proved that 30 min of theoretical falsification saves 8h+ GPU.
249    // Contract: contracts/chinchilla-gate-v1.yaml.
250    if let Some(arch) = init_arch.as_ref() {
251        let n_params = estimate_param_count(arch);
252        let d_tokens = (num_steps as u64)
253            .saturating_mul(batch_size as u64)
254            .saturating_mul(seq_length as u64);
255        let ratio = d_tokens as f64 / n_params as f64;
256        let suggested_steps = if batch_size > 0 && seq_length > 0 {
257            (20 * n_params) / (batch_size as u64 * seq_length as u64)
258        } else {
259            0
260        };
261
262        if ratio < 10.0 && !force_under_provisioned {
263            return Err(CliError::ValidationFailed(format!(
264                "[P0-J] Chinchilla hard gate (chinchilla-gate-v1): \
265                 train tokens D = {} ({:.1}M) is {:.3}× param count N = {} ({:.1}M); \
266                 Chinchilla compute-optimal target is D ≈ 20·N (Hoffmann et al. 2022, arXiv:2203.15556). \
267                 Run REJECTED: D/N < 10× will produce mode collapse / repetitive degeneration \
268                 (Holtzman et al. 2019, arXiv:1904.09751). \
269                 Increase --num-steps to ~{} OR widen --dataset corpus OR reduce model size. \
270                 To bypass anyway (e.g. ablation studies, resumed runs), pass --force-under-provisioned.",
271                d_tokens,
272                d_tokens as f64 / 1e6,
273                ratio,
274                n_params,
275                n_params as f64 / 1e6,
276                suggested_steps,
277            )));
278        }
279
280        if ratio < 10.0 {
281            // Bypassed via --force-under-provisioned: emit a loud warning
282            // so the override is captured in the log.
283            eprintln!(
284                "[P0-J] Chinchilla gate BYPASSED via --force-under-provisioned: \
285                 D = {} ({:.1}M) is {:.3}× N = {} ({:.1}M). \
286                 Run will likely produce repetitive/degenerate output. \
287                 You explicitly opted in.",
288                d_tokens,
289                d_tokens as f64 / 1e6,
290                ratio,
291                n_params,
292                n_params as f64 / 1e6,
293            );
294        } else if ratio < 20.0 {
295            // 10× ≤ D/N < 20× — below compute-optimal but training will
296            // still progress meaningfully. Warning, not error.
297            eprintln!(
298                "[P1-A] Chinchilla gate WARNING: D = {} ({:.1}M) is {:.1}× N = {} ({:.1}M); \
299                 below compute-optimal 20·N target — model has room for more training. \
300                 Suggested --num-steps for 20·N: ~{}.",
301                d_tokens,
302                d_tokens as f64 / 1e6,
303                ratio,
304                n_params,
305                n_params as f64 / 1e6,
306                suggested_steps,
307            );
308        }
309    }
310
311    // Validation: GATE-TRAIN-003 requires target_val_loss > 0.
312    if hp.target_val_loss <= 0.0 {
313        return Err(CliError::ValidationFailed(format!(
314            "target_val_loss must be positive, got {}",
315            hp.target_val_loss
316        )));
317    }
318    if num_steps == 0 {
319        return Err(CliError::ValidationFailed(
320            "num_steps must be > 0".to_string(),
321        ));
322    }
323    if steps_per_epoch == 0 {
324        return Err(CliError::ValidationFailed(
325            "steps_per_epoch must be > 0".to_string(),
326        ));
327    }
328
329    let config = PretrainConfig {
330        dataset_path: dataset.to_path_buf(),
331        tokenizer_dir: tokenizer.to_path_buf(),
332        run_dir: run_dir.to_path_buf(),
333        lr_max: hp.lr_max,
334        lr_min: (hp.lr_max * 1.0e-2).max(1.0e-7),
335        warmup_steps: hp.warmup_steps,
336        total_steps: num_steps,
337        batch_size,
338        seq_length,
339        steps_per_epoch,
340        seed,
341        grad_clip: 1.0,
342        weight_decay: 0.01,
343        target_val_loss: hp.target_val_loss,
344        // Patience widened from 2 → 5 epochs for from-scratch runs (2026-04-26).
345        // Rationale: a 50K-step run early-stopped at epoch 5/24 even though
346        // train_loss was monotonically decreasing 10.01 → 9.54 (Δ=−0.47);
347        // val_loss noise on 16k-token val set (now 131k) had stdev ~0.04,
348        // same scale as epoch-over-epoch improvement signal during early
349        // training. 5 patience epochs gives the optimizer time to push past
350        // local plateaus without ending an obviously-still-converging run.
351        patience_epochs: 5,
352        // Minimum epochs before early-stop. Bumped 1 → 3 so the warmup
353        // window (1000 steps = 1 epoch at 1000 steps_per_epoch, or 0.5
354        // epoch at 2000 steps_per_epoch) plus 1-2 initial epochs of post-
355        // warmup learning are guaranteed to complete before any early-stop
356        // signal is honoured.
357        min_epochs_before_early_stop: 3,
358        regime: hp.regime,
359    };
360
361    if !json_output {
362        print_header(&config);
363        // GATE-GPUTRAIN-002 visibility: print the resolved Device so the
364        // operator can confirm which backend was selected. `auto` is the
365        // only spec that may silently fall back, and this print makes
366        // the fall-back visible at startup.
367        output::kv("  Device", resolved_device.to_string());
368        println!();
369    }
370
371    let status = if synthetic {
372        drive_synthetic(
373            config.clone(),
374            num_steps,
375            steps_per_epoch,
376            hp.target_val_loss,
377            json_output,
378        )?
379    } else {
380        drive_real(
381            config.clone(),
382            dataset,
383            hp.lr_max,
384            seq_length,
385            batch_size,
386            seed,
387            resolved_device,
388            json_output,
389            init_arch.as_ref(),
390            init,
391            val_shard,
392        )?
393    };
394
395    // Contract: non-OK terminal statuses map to non-zero exit codes so
396    // operators can recognize divergence / NaN from shell `$?`.
397    match status {
398        RunStatus::Aborted(abort) => Err(abort_to_err(&abort)),
399        RunStatus::Ok { .. } | RunStatus::EarlyStop { .. } => Ok(()),
400    }
401}
402
403/// Synthetic drive: deterministic linear-decay `StepFn` and a scripted
404/// val-loss sequence so the full gate surface (GATE-TRAIN-005/007/008)
405/// is exercised end-to-end with no corpus I/O.
406fn drive_synthetic(
407    config: PretrainConfig,
408    num_steps: usize,
409    steps_per_epoch: usize,
410    target_val_loss: f32,
411    json_output: bool,
412) -> Result<RunStatus> {
413    let step_fn = LinearDecaySynthetic {
414        start_loss: (target_val_loss * 2.0).max(1.5),
415        decay_per_step: (target_val_loss * 0.01).max(1.0e-4),
416        grad_norm: 0.8,
417    };
418    let num_epochs = num_steps.div_ceil(steps_per_epoch);
419    let mut sequence = Vec::with_capacity(num_epochs + 2);
420    let start_val = (target_val_loss * 1.8).max(3.0);
421    for i in 0..(num_epochs + 2) {
422        let t = i as f32 / (num_epochs.max(1) as f32);
423        sequence.push(target_val_loss + (start_val - target_val_loss) * (1.0 - t).max(0.0));
424    }
425    let val_fn = ScriptedVal { sequence };
426    // Synthetic drive has no real weights to checkpoint.
427    run_and_report(config, step_fn, val_fn, None, json_output)
428}
429
430/// Contract apr-pretrain-from-init-v1 §init_load_semantics + §init_error_semantics:
431/// validate `--init <PATH>` BEFORE any trainer allocation. Falsifies
432/// FALSIFY-APR-PRETRAIN-INIT-003 (missing-file) + -004 (invalid-magic).
433///
434/// Returns Ok on a valid APR file (existence + magic bytes verified).
435/// Architecture extraction + weight load are §50.4 step 5f.4 — the
436/// caller (`run()`) extracts the config via `model_config::read_apr_architecture`
437/// and passes both to `build_shared_trainer_with_init` per
438/// `apr-pretrain-arch-polymorphic-v1` §init_load_semantics.
439fn validate_init_apr_path(path: &Path) -> Result<()> {
440    let mut file = std::fs::File::open(path).map_err(|e| {
441        CliError::ValidationFailed(format!(
442            "FALSIFY-APR-PRETRAIN-INIT-003: --init path does not exist or is unreadable: {} ({e})",
443            path.display()
444        ))
445    })?;
446    let mut magic = [0u8; 4];
447    use std::io::Read;
448    file.read_exact(&mut magic).map_err(|e| {
449        CliError::ValidationFailed(format!(
450            "FALSIFY-APR-PRETRAIN-INIT-004: --init file too short to contain APR magic bytes: {} ({e})",
451            path.display()
452        ))
453    })?;
454    // APR magic bytes per `crates/aprender-core/src/format/kani_proofs.rs`:
455    //   APR\0 = [0x41, 0x50, 0x52, 0x00] (v2)
456    //   APRN  = [0x41, 0x50, 0x52, 0x4E] (v1)
457    const APR_MAGIC_V2: [u8; 4] = [0x41, 0x50, 0x52, 0x00];
458    const APR_MAGIC_V1: [u8; 4] = [0x41, 0x50, 0x52, 0x4E];
459    if magic != APR_MAGIC_V2 && magic != APR_MAGIC_V1 {
460        return Err(CliError::ValidationFailed(format!(
461            "FALSIFY-APR-PRETRAIN-INIT-004: --init file is not a valid APR file (magic={:02X?}, expected {:02X?} or {:02X?}): {}",
462            magic, APR_MAGIC_V2, APR_MAGIC_V1, path.display()
463        )));
464    }
465    Ok(())
466}
467
468/// GATE-ARCH-370M-011 pre-flight: count the tokenizer's vocabulary entries
469/// from `vocab.json` and assert the count matches `target_vocab_size`
470/// before any trainer allocation.
471///
472/// Per `apr-pretrain-arch-polymorphic-v1` §qwen_tokenizer_vocab_compatibility
473/// (PR #1473), the target is now POLYMORPHIC — when `--init <PATH>` is set,
474/// the caller passes the extracted-arch's vocab_size (e.g., 151_936 for
475/// Qwen2.5-0.5B); otherwise `Llama370MConfig::VOCAB_SIZE` (50_257) for
476/// the §24/§25 from-scratch baseline.
477///
478/// Any mismatch aborts the dispatch with a clear error naming both values
479/// and the violated invariant — the N-09 OOB escape in `Embedding::forward`
480/// would otherwise silently corrupt training.
481///
482/// Discharges FALSIFY-APR-PRETRAIN-ARCH-005 (Qwen tokenizer passes with
483/// Qwen target) and FALSIFY-APR-PRETRAIN-ARCH-006 (Qwen tokenizer fails
484/// with Llama target).
485fn preflight_tokenizer_vocab_matches_target(
486    tokenizer_dir: &Path,
487    target_vocab_size: usize,
488    init_is_some: bool,
489) -> Result<()> {
490    let vocab_path = tokenizer_dir.join("vocab.json");
491    let vocab_json = std::fs::read_to_string(&vocab_path).map_err(|e| {
492        CliError::ValidationFailed(format!(
493            "GATE-ARCH-370M-011 pre-flight: cannot read {} ({e})",
494            vocab_path.display()
495        ))
496    })?;
497    let vocab: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&vocab_json)
498        .map_err(|e| {
499            CliError::ValidationFailed(format!(
500                "GATE-ARCH-370M-011 pre-flight: {} is not a valid vocab.json: {e}",
501                vocab_path.display()
502            ))
503        })?;
504    // §55: when --init is set (polymorphic path with HF-distributed
505    // checkpoint), allow tokenizer_vocab ≤ model_vocab to admit Qwen-style
506    // reserved-slot vocabularies. When --init is absent (§24/§25 from-scratch
507    // baseline), enforce strict equality to preserve INV-ARCH-370M-006.
508    if init_is_some {
509        assert_tokenizer_vocab_within_model_bound(vocab.len(), target_vocab_size)
510            .map_err(CliError::ValidationFailed)
511    } else {
512        assert_tokenizer_vocab_matches_model(vocab.len(), target_vocab_size)
513            .map_err(CliError::ValidationFailed)
514    }
515}
516
517/// Real-corpus drive: build a shared 370M trainer (CPU or CUDA), split
518/// the shard stream head-off into a held-out validation set, and run a
519/// full forward + backward + AdamW step per training batch.
520///
521/// When `device.is_cuda()`, the `cuda` feature must be compiled in —
522/// otherwise this surfaces a clear error rather than silently falling
523/// back to CPU (GATE-GPUTRAIN-002, contract gpu-training-backend-v1).
524#[allow(clippy::too_many_arguments)]
525fn drive_real(
526    config: PretrainConfig,
527    dataset: &Path,
528    lr: f32,
529    seq_length: usize,
530    batch_size: usize,
531    seed: u64,
532    device: Device,
533    json_output: bool,
534    init_arch: Option<&TransformerConfig>,
535    init_path: Option<&Path>,
536    val_shard: Option<&Path>,
537) -> Result<RunStatus> {
538    // GATE-ARCH-370M-011 / INV-ARCH-370M-006 — refuse to dispatch a real
539    // training step when the tokenizer vocab_size and the model vocab_size
540    // disagree. The N-09 OOB escape guard in Embedding::forward masks the
541    // mismatch at runtime → silent garbage gradients otherwise. Synthetic
542    // drive skips this check because it never touches the real model.
543    // Per `apr-pretrain-arch-polymorphic-v1` §qwen_tokenizer_vocab_compatibility
544    // (§50.4 step 5d/5f.4): when --init is set, gate by the EXTRACTED arch's
545    // vocab_size; otherwise gate by the §24/§25 baseline Llama370MConfig::VOCAB_SIZE,
546    // preserving regression-free behavior (FALSIFY-002 + FALSIFY-005 + FALSIFY-006).
547    let target_vocab = init_arch
548        .map(|cfg| cfg.vocab_size)
549        .unwrap_or(Llama370MConfig::VOCAB_SIZE);
550    preflight_tokenizer_vocab_matches_target(
551        &config.tokenizer_dir,
552        target_vocab,
553        init_arch.is_some(),
554    )?;
555
556    // MVP: pad_id/eos_id both 0. All sequences are uniform length
557    // (seq_length + 1) so LMBatch::from_sequences takes the shared
558    // layout path and pad_id is never used for padding. The real
559    // tokenizer's special-token ids will plumb through in a follow-up.
560    //
561    // wrap_around=true: when the corpus shards are exhausted before
562    // --num-steps is reached, reset cursor to shard 0 and continue.
563    // This is standard ML-training behaviour (matches PyTorch /
564    // HuggingFace). Without it, an 18M-token corpus exhausts in ~2
565    // epochs of a 5K-step run with batch=16 seq=512, and the
566    // Cuda*StepFn falls back to placeholder loss `(1.0, 1.0)` — silently
567    // producing garbage gradients. See spec §22 (PR #1073) for the
568    // root-cause investigation.
569    let mut iter = ShardBatchIter::new(dataset, batch_size, seq_length, 0, 0)
570        .map_err(|e| {
571            CliError::ValidationFailed(format!(
572                "dataset shard iterator init failed: {e} (path={})",
573                dataset.display()
574            ))
575        })?
576        .with_wrap_around(true)
577        // SPEC §82 P2-B: surface data starvation. When the corpus cycles
578        // mid-run, emit a stderr line so operators can detect that the
579        // step budget exceeds the corpus capacity (per Chinchilla, train
580        // tokens D ≈ 20·N — if D is small, the corpus wraps repeatedly
581        // and the model memorizes instead of generalizing).
582        .with_warn_on_wrap_around(true);
583
584    // SPEC §84 P2-F (apr-pretrain-val-shard-v1): held-out val source.
585    //
586    // When --val-shard <DIR> is provided, drain HELD_OUT_BATCHES from a
587    // dedicated independent shard iterator over <DIR>; the training iter
588    // stays at offset 0 (no batch theft). This makes val_loss comparable
589    // across runs whose --dataset composition changes (the P2-C audit-
590    // falsified result was confounded by val sets drawn from different
591    // corpus distributions — see evidence/p2c-2026-05-17/findings.md).
592    //
593    // When --val-shard is None, the historical "first N batches of
594    // --dataset" behaviour is preserved.
595    let held_out: Vec<LMBatch> = if let Some(val_dir) = val_shard {
596        let mut val_iter = ShardBatchIter::new(val_dir, batch_size, seq_length, 0, 0)
597            .map_err(|e| {
598                CliError::ValidationFailed(format!(
599                    "FALSIFY-PRETRAIN-VAL-SHARD-001: --val-shard iterator init failed: {e} \
600                     (path={})",
601                    val_dir.display()
602                ))
603            })?
604            // Per INV-PRETRAIN-VAL-SHARD-002 — the val shard is NOT
605            // wrap-around. A short val corpus draws short held_out
606            // (potentially < HELD_OUT_BATCHES batches) and the run
607            // proceeds; we only fail if zero batches are drawn.
608            .with_wrap_around(false);
609        let mut batches: Vec<LMBatch> = Vec::with_capacity(HELD_OUT_BATCHES);
610        for _ in 0..HELD_OUT_BATCHES {
611            match val_iter.next() {
612                Some(b) => batches.push(b),
613                None => break,
614            }
615        }
616        if batches.is_empty() {
617            return Err(CliError::ValidationFailed(format!(
618                "FALSIFY-PRETRAIN-VAL-SHARD-003: --val-shard {} is too small to yield any \
619                 held-out batches at batch_size={} seq_length={}",
620                val_dir.display(),
621                batch_size,
622                seq_length
623            )));
624        }
625        if !json_output {
626            eprintln!(
627                "[P2-F] held-out val source = --val-shard {} ({} batches)",
628                val_dir.display(),
629                batches.len()
630            );
631        }
632        batches
633    } else {
634        // Reserve the first `HELD_OUT_BATCHES` batches as the held-out val
635        // set; the remainder feeds RealStepFn.
636        let mut batches: Vec<LMBatch> = Vec::with_capacity(HELD_OUT_BATCHES);
637        for _ in 0..HELD_OUT_BATCHES {
638            match iter.next() {
639                Some(b) => batches.push(b),
640                None => break,
641            }
642        }
643        if batches.is_empty() {
644            return Err(CliError::ValidationFailed(format!(
645                "dataset {} is too small to reserve any held-out batches",
646                dataset.display()
647            )));
648        }
649        batches
650    };
651
652    if device.is_cuda() {
653        // §50.4 step 5f.5 SHIPPED (this PR): CUDA path with --init is now
654        // wired symmetric to the CPU path via
655        // `entrenar::train::pretrain_real_cuda::build_shared_cuda_trainer_with_init`.
656        // The same §50.4 step-5f machinery composes through both backends:
657        //   5c: build_transformer_config(init_arch)
658        //   5f.1: validate_pretrain_init_arch_compatible(init_arch) — encoder rejection
659        //   5f.2: load_init_tensors_from_apr(path) — read APR weights
660        //   5f.3: populate_trainer_from_init_tensors(transformer, &tensors) — populate CPU model
661        //   5f.5 (this PR): CudaTransformerTrainer::with_model uploads populated
662        //                   blocks / norm / lm_head to GPU.
663        //
664        // Per `apr-pretrain-arch-polymorphic-v1` v1.7.0 §FALSIFY-APR-PRETRAIN-INIT-CUDA-001,
665        // the const FALSIFY_APR_PRETRAIN_INIT_CUDA_001_MSG is repurposed as a
666        // drift-prevention sentinel — if a future refactor re-introduces a
667        // fail-fast on the CUDA + --init path, the test that pins the const
668        // will fail and surface the regression.
669        drive_real_cuda(
670            config,
671            iter,
672            held_out,
673            lr,
674            seq_length,
675            seed,
676            json_output,
677            init_arch,
678            init_path,
679        )
680    } else {
681        drive_real_cpu(
682            config,
683            iter,
684            held_out,
685            lr,
686            seq_length,
687            seed,
688            json_output,
689            init_arch,
690            init_path,
691        )
692    }
693}
694
695/// CPU backend for `drive_real` — builds a `TransformerTrainer`
696/// (`aprender::Tensor` + trueno SIMD) and wires `RealStepFn` /
697/// `RealValFn` / `AprCheckpointFn`.
698#[allow(clippy::too_many_arguments)]
699fn drive_real_cpu(
700    config: PretrainConfig,
701    iter: entrenar::train::shard_reader::ShardBatchIter,
702    held_out: Vec<LMBatch>,
703    lr: f32,
704    seq_length: usize,
705    seed: u64,
706    json_output: bool,
707    init_arch: Option<&TransformerConfig>,
708    init_path: Option<&Path>,
709) -> Result<RunStatus> {
710    // §50.4 step 5f.4: when --init is set, build the trainer via the
711    // polymorphic builder (extracts arch + loads + populates init tensors).
712    // When --init is absent, use the existing from-scratch baseline builder
713    // so the §24/§25 evidence remains regression-free.
714    let trainer = if init_arch.is_some() || init_path.is_some() {
715        build_shared_trainer_with_init(lr, seq_length, seed, init_arch, init_path)
716            .map_err(CliError::ValidationFailed)?
717    } else {
718        build_shared_trainer(lr, seq_length, seed)
719    };
720    let step_fn = RealStepFn::new(trainer.clone(), Box::new(iter));
721    let val_fn = RealValFn::new(trainer.clone(), held_out);
722    let (ckpt_name, ckpt_arch) = checkpoint_name_and_arch(init_arch);
723    let ckpt: Box<dyn CheckpointFn> =
724        Box::new(AprCheckpointFn::new(trainer, &ckpt_name, &ckpt_arch));
725    run_and_report(config, step_fn, val_fn, Some(ckpt), json_output)
726}
727
728/// CUDA backend for `drive_real` — builds a `CudaTransformerTrainer`
729/// and wires `CudaRealStepFn` / `CudaRealValFn` / `CudaAprCheckpointFn`
730/// (task #132 Phase 2, contract gpu-training-backend-v1).
731///
732/// When the `cuda` feature is NOT compiled in, this returns a clear
733/// build-time error so operators who asked for `--device cuda` do not
734/// silently get the CPU path (GATE-GPUTRAIN-002 / FM-GPUTRAIN-SILENT-CPU).
735#[cfg(feature = "cuda")]
736#[allow(clippy::too_many_arguments)]
737fn drive_real_cuda(
738    config: PretrainConfig,
739    iter: entrenar::train::shard_reader::ShardBatchIter,
740    held_out: Vec<LMBatch>,
741    lr: f32,
742    seq_length: usize,
743    seed: u64,
744    json_output: bool,
745    init_arch: Option<&TransformerConfig>,
746    init_path: Option<&Path>,
747) -> Result<RunStatus> {
748    use entrenar::train::pretrain_real_cuda::{
749        build_shared_cuda_trainer, build_shared_cuda_trainer_with_init, CudaAprCheckpointFn,
750        CudaRealStepFn, CudaRealValFn,
751    };
752    // §50.4 step 5f.5: when --init is set on the CUDA path, build via the
753    // polymorphic builder (extracts arch + loads + populates init tensors,
754    // then uploads to GPU). When --init is absent, use the existing
755    // from-scratch baseline so the §24/§25 evidence remains regression-free
756    // and INV-ARCH-370M-001 stays enforced on the from-scratch CUDA path.
757    let trainer = if init_arch.is_some() || init_path.is_some() {
758        build_shared_cuda_trainer_with_init(lr, seq_length, seed, init_arch, init_path).map_err(
759            |e| {
760                CliError::ValidationFailed(format!(
761                    "GATE-GPUTRAIN-002: CUDA trainer allocation (--init path) failed: {e}. \
762                     See contracts/entrenar/gpu-training-backend-v1.yaml and \
763                     contracts/apr-pretrain-arch-polymorphic-v1.yaml v1.7.0 \
764                     §FALSIFY-APR-PRETRAIN-INIT-CUDA-001 — this path is only \
765                     reachable when the binary was built with `--features cuda`.",
766                ))
767            },
768        )?
769    } else {
770        build_shared_cuda_trainer(lr, seq_length, seed).map_err(|e| {
771            CliError::ValidationFailed(format!(
772                "GATE-GPUTRAIN-002: CUDA trainer allocation failed: {e}. \
773                 See contracts/entrenar/gpu-training-backend-v1.yaml and \
774                 memory/feedback_cuda_feature_footgun.md — this path is \
775                 only reachable when the binary was built with `--features cuda`.",
776            ))
777        })?
778    };
779    let step_fn = CudaRealStepFn::new(trainer.clone(), Box::new(iter));
780    let val_fn = CudaRealValFn::new(trainer.clone(), held_out);
781    // SPEC-SHIP-TWO-001 §81 P0-D: pass --tokenizer through so each
782    // checkpoint embeds the tokenizer.json (apr qa requires this).
783    let (ckpt_name, ckpt_arch) = checkpoint_name_and_arch(init_arch);
784    let ckpt: Box<dyn CheckpointFn> = Box::new(
785        CudaAprCheckpointFn::new(trainer, &ckpt_name, &ckpt_arch)
786            .with_tokenizer_dir(&config.tokenizer_dir),
787    );
788    run_and_report(config, step_fn, val_fn, Some(ckpt), json_output)
789}
790
791/// CUDA backend stub when the `cuda` feature is NOT compiled in.
792///
793/// This is the load-bearing gate that prevents FM-GPUTRAIN-SILENT-CPU:
794/// if a user passes `--device cuda` on an apr binary built without
795/// CUDA support, they see a clear "rebuild with --features cuda" error
796/// rather than a 14-minute CPU run masquerading as GPU training
797/// (task #132 lambda-labs incident, 2026-04-21).
798#[cfg(not(feature = "cuda"))]
799#[allow(clippy::too_many_arguments)]
800fn drive_real_cuda(
801    _config: PretrainConfig,
802    _iter: entrenar::train::shard_reader::ShardBatchIter,
803    _held_out: Vec<LMBatch>,
804    _lr: f32,
805    _seq_length: usize,
806    _seed: u64,
807    _json_output: bool,
808    _init_arch: Option<&TransformerConfig>,
809    _init_path: Option<&Path>,
810) -> Result<RunStatus> {
811    Err(CliError::ValidationFailed(
812        "GATE-GPUTRAIN-002: --device cuda was requested but this `apr` \
813         binary was built WITHOUT the `cuda` feature. \
814         Rebuild with `cargo build --release --features cuda` or use \
815         `--device cpu`. See memory/feedback_cuda_feature_footgun.md \
816         (contract gpu-training-backend-v1 / task #132 Phase 2)."
817            .into(),
818    ))
819}
820
821/// Shared helper: construct the `PretrainLoop`, run it, print the
822/// terminal report, and bubble the `RunStatus` back for exit-code
823/// mapping. `checkpoint_fn` — when `Some` — writes an APR file per
824/// epoch that passes GATE-TRAIN-005.
825fn run_and_report<S: StepFn, V: ValFn>(
826    config: PretrainConfig,
827    step_fn: S,
828    val_fn: V,
829    checkpoint_fn: Option<Box<dyn CheckpointFn>>,
830    json_output: bool,
831) -> Result<RunStatus> {
832    // Captured before `config` moves into the loop: the terminal verdict has to
833    // compare the final val_loss against the target the user actually asked for.
834    let target_val_loss = config.target_val_loss;
835    let mut loop_ = PretrainLoop::new(config, step_fn, val_fn);
836    if let Some(ckpt) = checkpoint_fn {
837        loop_ = loop_.with_checkpoint_fn(ckpt);
838    }
839    let status = loop_.run();
840    report(&status, &loop_, target_val_loss, json_output)?;
841    Ok(status)
842}
843
844fn abort_to_err(abort: &PretrainAbort) -> CliError {
845    match abort {
846        PretrainAbort::Divergence { .. } | PretrainAbort::DivergenceAtEpochZero { .. } => {
847            CliError::ValidationFailed(format!(
848                "GATE-TRAIN-005 ship-blocker fired: {abort}. See \
849                 contracts/training-loop-pretrain-v1.yaml and \
850                 memory/project_ship_two_001_model1_qlora_divergence.md"
851            ))
852        }
853        PretrainAbort::NumericalInstability { .. } => {
854            CliError::ValidationFailed(format!("GATE-TRAIN-007 NaN/Inf guard fired: {abort}"))
855        }
856        PretrainAbort::ThroughputOutOfRange { .. } => CliError::ValidationFailed(format!(
857            "GATE-TRAIN-008 throughput-range guard fired: {abort}"
858        )),
859    }
860}
861
862fn print_header(cfg: &PretrainConfig) {
863    output::header("apr pretrain — SHIP-TWO-001 MODEL-2 training loop");
864    println!();
865    output::section("Configuration");
866    output::kv("  Dataset", cfg.dataset_path.display().to_string());
867    output::kv("  Tokenizer", cfg.tokenizer_dir.display().to_string());
868    output::kv("  Run dir", cfg.run_dir.display().to_string());
869    output::kv("  LR max", format!("{:.2e}", cfg.lr_max));
870    output::kv("  Total steps", cfg.total_steps.to_string());
871    output::kv("  Warmup steps", cfg.warmup_steps.to_string());
872    output::kv(
873        "  Batch × seq",
874        format!("{} × {}", cfg.batch_size, cfg.seq_length),
875    );
876    output::kv("  Steps / epoch", cfg.steps_per_epoch.to_string());
877    output::kv("  Seed", cfg.seed.to_string());
878    output::kv("  Target val_loss", format!("{:.2}", cfg.target_val_loss));
879    println!();
880}
881
882/// Did the run reach the target val_loss the user asked for?
883///
884/// `None` for an aborted run — there is no final loss to compare.
885///
886/// Until this existed, `RunStatus::Ok` printed the literal string "CONVERGED"
887/// without ever looking at `target_val_loss`: a run that finished at 3.0000
888/// against a target of 0.001 reported CONVERGED with exit 0, and the JSON
889/// report carried neither the target nor a verdict, so a machine consumer
890/// could not recover it either.
891pub(crate) fn reached_target(status: &RunStatus, target_val_loss: f32) -> Option<bool> {
892    match status {
893        RunStatus::Ok { final_val_loss, .. } => Some(*final_val_loss <= target_val_loss),
894        RunStatus::EarlyStop { best_val_loss, .. } => Some(*best_val_loss <= target_val_loss),
895        RunStatus::Aborted(_) => None,
896    }
897}
898
899fn report<S: entrenar::train::pretrain::StepFn, V: entrenar::train::pretrain::ValFn>(
900    status: &RunStatus,
901    loop_: &PretrainLoop<S, V>,
902    target_val_loss: f32,
903    json_output: bool,
904) -> Result<()> {
905    if json_output {
906        let report = PretrainReport::from(status, loop_, target_val_loss);
907        let json = serde_json::to_string_pretty(&report)
908            .map_err(|e| CliError::InvalidFormat(e.to_string()))?;
909        println!("{json}");
910        return Ok(());
911    }
912
913    output::section("Run Result");
914    match status {
915        RunStatus::Ok {
916            final_val_loss,
917            epochs_completed,
918        } => {
919            if *final_val_loss <= target_val_loss {
920                println!(
921                    "  {} CONVERGED  final val_loss={:.4} <= target {:.4} after {} epoch(s)",
922                    "OK".green().bold(),
923                    final_val_loss,
924                    target_val_loss,
925                    epochs_completed
926                );
927            } else {
928                println!(
929                    "  {} NOT_CONVERGED  final val_loss={:.4} > target {:.4} after {} epoch(s)",
930                    "OK".yellow().bold(),
931                    final_val_loss,
932                    target_val_loss,
933                    epochs_completed
934                );
935            }
936        }
937        RunStatus::EarlyStop {
938            best_val_loss,
939            epochs_completed,
940        } => {
941            println!(
942                "  {} EARLY_STOP  best val_loss={:.4} (target {:.4}) after {} epoch(s)",
943                "OK".yellow().bold(),
944                best_val_loss,
945                target_val_loss,
946                epochs_completed
947            );
948        }
949        RunStatus::Aborted(abort) => {
950            println!("  {} ABORTED  {}", "FAIL".red().bold(), abort);
951        }
952    }
953    output::kv("  Steps recorded", loop_.step_metrics().len().to_string());
954    output::kv(
955        "  Epochs recorded",
956        loop_.epoch_artifacts().len().to_string(),
957    );
958    println!();
959    Ok(())
960}
961
962#[derive(serde::Serialize)]
963struct PretrainReport {
964    status: String,
965    detail: Option<String>,
966    final_val_loss: Option<f32>,
967    /// The `--target-val-loss` the run was asked to reach.
968    target_val_loss: f32,
969    /// `final_val_loss <= target_val_loss`. `None` for an aborted run.
970    ///
971    /// Without this a machine consumer could not tell a run that hit its
972    /// target from one that missed it by 1.8x — both reported status "OK".
973    converged: Option<bool>,
974    epochs_completed: usize,
975    steps_recorded: usize,
976    val_loss_history: Vec<f32>,
977    /// Per-step `StepMetrics` captured by `PretrainLoop` (GATE-TRAIN-001
978    /// contract `training-loop-pretrain-v1.yaml::per_step_metrics.required`).
979    ///
980    /// Emitted so downstream consumers can discharge FALSIFY-GPUTRAIN-005
981    /// (step-time < 500 ms on RTX 4090 for 370M) and FALSIFY-GPUTRAIN-006
982    /// (same-seed reproducibility — two cuda:0 runs at seed=0 must match
983    /// on every step's train_loss within `AC_GPUTRAIN_006_MAX_SEED_LOSS_DELTA`
984    /// = 1e-5) directly from the `--json` output, rather than having to
985    /// parse run-dir checkpoint metadata.
986    per_step_metrics: Vec<entrenar::train::pretrain::StepMetrics>,
987}
988
989impl PretrainReport {
990    fn from<S: entrenar::train::pretrain::StepFn, V: entrenar::train::pretrain::ValFn>(
991        status: &RunStatus,
992        loop_: &PretrainLoop<S, V>,
993        target_val_loss: f32,
994    ) -> Self {
995        let (status_name, detail, final_val_loss, epochs_completed) = match status {
996            RunStatus::Ok {
997                final_val_loss,
998                epochs_completed,
999            } => (
1000                "OK".to_string(),
1001                None,
1002                Some(*final_val_loss),
1003                *epochs_completed,
1004            ),
1005            RunStatus::EarlyStop {
1006                best_val_loss,
1007                epochs_completed,
1008            } => (
1009                "EARLY_STOP".to_string(),
1010                None,
1011                Some(*best_val_loss),
1012                *epochs_completed,
1013            ),
1014            RunStatus::Aborted(abort) => (
1015                "ABORTED".to_string(),
1016                Some(abort.to_string()),
1017                None,
1018                loop_.epoch_artifacts().len(),
1019            ),
1020        };
1021        PretrainReport {
1022            status: status_name,
1023            detail,
1024            final_val_loss,
1025            target_val_loss,
1026            converged: reached_target(status, target_val_loss),
1027            epochs_completed,
1028            steps_recorded: loop_.step_metrics().len(),
1029            val_loss_history: loop_.val_loss_history().to_vec(),
1030            per_step_metrics: loop_.step_metrics().to_vec(),
1031        }
1032    }
1033}
1034
1035#[cfg(test)]
1036mod tests {
1037    use super::*;
1038    use tempfile::TempDir;
1039
1040    // ─── Convergence verdict (dogfood 0.63.0, issue #2374 finding 11) ────────
1041    //
1042    // `apr pretrain` printed "OK CONVERGED" for every non-diverging run: at
1043    // --target-val-loss 0.001 it reported CONVERGED with a final val_loss of
1044    // 3.0000, and across five targets spanning four orders of magnitude the
1045    // verdict text never changed. The target was never compared against.
1046
1047    #[test]
1048    fn missing_the_target_is_not_convergence() {
1049        // The exact reported case: target 0.001, final 3.0000.
1050        let status = RunStatus::Ok {
1051            final_val_loss: 3.0,
1052            epochs_completed: 1,
1053        };
1054        assert_eq!(
1055            reached_target(&status, 0.001),
1056            Some(false),
1057            "final 3.0000 against target 0.001 is NOT converged"
1058        );
1059    }
1060
1061    #[test]
1062    fn hitting_the_target_is_convergence() {
1063        let status = RunStatus::Ok {
1064            final_val_loss: 1.5,
1065            epochs_completed: 3,
1066        };
1067        assert_eq!(reached_target(&status, 2.2), Some(true));
1068    }
1069
1070    #[test]
1071    fn the_verdict_moves_with_the_target() {
1072        // The defect was that it did NOT: five targets, one verdict. Sweep the
1073        // same reported final loss across the targets from the repro.
1074        let status = RunStatus::Ok {
1075            final_val_loss: 3.0,
1076            epochs_completed: 1,
1077        };
1078        let verdicts: Vec<Option<bool>> = [0.001_f32, 2.2, 3.5, 5.0]
1079            .iter()
1080            .map(|t| reached_target(&status, *t))
1081            .collect();
1082        assert_eq!(
1083            verdicts,
1084            vec![Some(false), Some(false), Some(true), Some(true)],
1085            "the verdict must track the target, not be constant"
1086        );
1087    }
1088
1089    #[test]
1090    fn exactly_on_target_counts_as_converged() {
1091        let status = RunStatus::Ok {
1092            final_val_loss: 2.2,
1093            epochs_completed: 1,
1094        };
1095        assert_eq!(
1096            reached_target(&status, 2.2),
1097            Some(true),
1098            "<= is the boundary"
1099        );
1100    }
1101
1102    #[test]
1103    fn early_stop_is_judged_on_its_best_loss() {
1104        let status = RunStatus::EarlyStop {
1105            best_val_loss: 4.0,
1106            epochs_completed: 2,
1107        };
1108        assert_eq!(reached_target(&status, 2.2), Some(false));
1109        assert_eq!(reached_target(&status, 4.5), Some(true));
1110    }
1111
1112    #[test]
1113    fn an_aborted_run_has_no_convergence_verdict() {
1114        // Divergence already has its own hard failure; it must not be reported
1115        // as "not converged" as though it merely fell short.
1116        let status = RunStatus::Aborted(PretrainAbort::DivergenceAtEpochZero { val_loss: 18.0 });
1117        assert_eq!(reached_target(&status, 2.2), None);
1118    }
1119
1120    /// SPEC §82 P0-H: when `--init` is absent, fall back to historical defaults
1121    /// so from-scratch 370M pretrain still produces `llama-370m-pretrain` /
1122    /// `LlamaForCausalLM` stamps.
1123    #[test]
1124    fn checkpoint_name_and_arch_default_when_no_init() {
1125        let (name, arch) = checkpoint_name_and_arch(None);
1126        assert_eq!(name, "llama-370m-pretrain");
1127        assert_eq!(arch, "LlamaForCausalLM");
1128    }
1129
1130    /// SPEC §82 P0-H: when `--init` is a Qwen2 model, stamp `qwen2-pretrain`
1131    /// and `Qwen2ForCausalLM` so the qwen2 GGUF family mapper handles the
1132    /// 72 Qwen2 attn biases instead of leaving them as passthrough names.
1133    #[test]
1134    fn checkpoint_name_and_arch_qwen2_init() {
1135        let mut cfg = TransformerConfig::llama2_7b();
1136        cfg.hf_architecture = Some("Qwen2ForCausalLM".to_string());
1137        cfg.hf_model_type = Some("qwen2".to_string());
1138        let (name, arch) = checkpoint_name_and_arch(Some(&cfg));
1139        assert_eq!(name, "qwen2-pretrain");
1140        assert_eq!(arch, "Qwen2ForCausalLM");
1141    }
1142
1143    /// SPEC §82 P0-H: a `--init` model that lacks `hf_architecture` falls back
1144    /// to `LlamaForCausalLM` rather than silently emitting an empty arch
1145    /// string. (Belt-and-suspenders for older APR files written before the
1146    /// hf_architecture field existed.)
1147    #[test]
1148    fn checkpoint_name_and_arch_init_without_hf_fields() {
1149        let cfg = TransformerConfig::llama2_7b();
1150        // llama2_7b() leaves hf_architecture and hf_model_type as None.
1151        let (name, arch) = checkpoint_name_and_arch(Some(&cfg));
1152        assert_eq!(name, "model-pretrain");
1153        assert_eq!(arch, "LlamaForCausalLM");
1154    }
1155
1156    /// Stage a `vocab.json` with exactly `n` distinct integer-string tokens at
1157    /// `<dir>/vocab.json`. Used by pre-flight gate tests + by other tests that
1158    /// need to get PAST the GATE-ARCH-370M-011 pre-flight to exercise a later
1159    /// failure mode (e.g. empty dataset shards).
1160    fn stage_vocab_json(dir: &std::path::Path, n: usize) {
1161        std::fs::create_dir_all(dir).expect("mkdir tokenizer dir");
1162        let mut obj = serde_json::Map::with_capacity(n);
1163        for i in 0..n {
1164            obj.insert(format!("t{i}"), serde_json::Value::from(i as u64));
1165        }
1166        let json = serde_json::to_string(&obj).expect("serialize");
1167        std::fs::write(dir.join("vocab.json"), json).expect("write vocab.json");
1168    }
1169
1170    /// SPEC §82 P1-A: parameter count estimator should be order-of-magnitude
1171    /// correct for known reference models. Qwen2.5-0.5B has ~500M params;
1172    /// our coarse formula should be within 2× of that.
1173    #[test]
1174    fn estimate_param_count_qwen2_05b_within_2x() {
1175        let mut cfg = TransformerConfig::llama2_7b();
1176        cfg.hidden_size = 896;
1177        cfg.num_hidden_layers = 24;
1178        cfg.num_attention_heads = 14;
1179        cfg.num_kv_heads = 2;
1180        cfg.intermediate_size = 4864;
1181        cfg.vocab_size = 151936;
1182        let n = estimate_param_count(&cfg);
1183        // True Qwen2.5-0.5B = ~494M. Our estimate counts tied embedding once
1184        // and ignores GQA reduction; expect ~400-700M.
1185        let ref_params: u64 = 494_000_000;
1186        assert!(
1187            n > ref_params / 2 && n < ref_params * 2,
1188            "Qwen2.5-0.5B estimate {n} should be within 2× of 494M",
1189        );
1190    }
1191
1192    /// SPEC §82 P1-A: estimator should scale super-linearly with depth.
1193    #[test]
1194    fn estimate_param_count_scales_with_layers() {
1195        let mut cfg = TransformerConfig::llama2_7b();
1196        cfg.hidden_size = 512;
1197        cfg.num_hidden_layers = 1;
1198        cfg.intermediate_size = 2048;
1199        cfg.vocab_size = 32000;
1200        let n1 = estimate_param_count(&cfg);
1201        cfg.num_hidden_layers = 24;
1202        let n24 = estimate_param_count(&cfg);
1203        // 24× per-layer params + shared embedding ≈ 5-6× total for small models
1204        // where embedding dominates per-layer contribution.
1205        assert!(
1206            n24 > n1 * 4,
1207            "24-layer model {n24} should be at least 4× 1-layer model {n1}",
1208        );
1209    }
1210
1211    // ─── SPEC §83 P0-J: Chinchilla hard-gate behavior ──────────
1212    //
1213    // The gate logic itself lives inline in `run()` so a full unit
1214    // test requires either calling `run()` (heavy — needs dataset
1215    // path + tokenizer dir) or factoring the math into a helper.
1216    // Below we test the math in isolation via a local helper; the
1217    // end-to-end CLI behavior is covered by integration tests in
1218    // tests/chinchilla_gate_test.rs (FALSIFY-CHINCHILLA-001..003).
1219
1220    /// Mirror of the inline gate math in `run()` — kept in sync via
1221    /// review. Returns Some(error_message) if rejected, None if
1222    /// accepted (with or without bypass).
1223    fn chinchilla_gate_check(
1224        arch: &TransformerConfig,
1225        num_steps: usize,
1226        batch_size: usize,
1227        seq_length: usize,
1228        force_under_provisioned: bool,
1229    ) -> Option<f64> {
1230        let n_params = estimate_param_count(arch);
1231        let d_tokens = (num_steps as u64)
1232            .saturating_mul(batch_size as u64)
1233            .saturating_mul(seq_length as u64);
1234        let ratio = d_tokens as f64 / n_params as f64;
1235        if ratio < 10.0 && !force_under_provisioned {
1236            Some(ratio)
1237        } else {
1238            None
1239        }
1240    }
1241
1242    fn qwen_05b_config() -> TransformerConfig {
1243        let mut cfg = TransformerConfig::llama2_7b();
1244        cfg.hidden_size = 896;
1245        cfg.num_hidden_layers = 24;
1246        cfg.num_attention_heads = 14;
1247        cfg.num_kv_heads = 2;
1248        cfg.intermediate_size = 4864;
1249        cfg.vocab_size = 151936;
1250        cfg.hf_architecture = Some("Qwen2ForCausalLM".to_string());
1251        cfg.hf_model_type = Some("qwen2".to_string());
1252        cfg
1253    }
1254
1255    /// FALSIFY-CHINCHILLA-001 (unit): §82 P2-A reproducer — 5000
1256    /// steps × 16 × 512 = 40.96M tokens against Qwen-0.5B (~494M
1257    /// params) = ratio 0.083× → REJECTED.
1258    #[test]
1259    fn chinchilla_hard_gate_rejects_under_provisioned() {
1260        let cfg = qwen_05b_config();
1261        let verdict = chinchilla_gate_check(&cfg, 5000, 16, 512, false);
1262        assert!(verdict.is_some(), "0.083× should be rejected");
1263        let ratio = verdict.expect("ratio");
1264        assert!(ratio < 0.1, "expected ratio < 0.1, got {ratio}");
1265    }
1266
1267    /// FALSIFY-CHINCHILLA-002 (unit): same config with bypass flag
1268    /// → accepted (returns None despite low ratio).
1269    #[test]
1270    fn chinchilla_hard_gate_bypasses_with_force_flag() {
1271        let cfg = qwen_05b_config();
1272        let verdict = chinchilla_gate_check(&cfg, 5000, 16, 512, true);
1273        assert!(verdict.is_none(), "force_under_provisioned must bypass");
1274    }
1275
1276    /// FALSIFY-CHINCHILLA-004 (unit): boundary at exactly D/N = 10
1277    /// passes; just below fails. Uses ceiling division to ensure
1278    /// the "exact" case actually meets or exceeds 10·N (integer
1279    /// truncation on `target_d / (bs*sl)` would land slightly below).
1280    #[test]
1281    fn chinchilla_hard_gate_boundary_10x() {
1282        let cfg = qwen_05b_config();
1283        let n = estimate_param_count(&cfg);
1284        let bs = 16u64;
1285        let sl = 512u64;
1286        let target_d = 10 * n;
1287        let bs_sl = bs * sl;
1288        // Ceiling division so D ≥ 10·N exactly (passes the gate).
1289        let exact_steps = (target_d + bs_sl - 1) / bs_sl;
1290        let verdict_exact =
1291            chinchilla_gate_check(&cfg, exact_steps as usize, bs as usize, sl as usize, false);
1292        assert!(
1293            verdict_exact.is_none(),
1294            "ratio ≥ 10.0 should PASS, got verdict={verdict_exact:?}"
1295        );
1296        // One full step less → below 10·N → REJECTED.
1297        let verdict_below = chinchilla_gate_check(
1298            &cfg,
1299            (exact_steps - 1) as usize,
1300            bs as usize,
1301            sl as usize,
1302            false,
1303        );
1304        assert!(
1305            verdict_below.is_some(),
1306            "ratio just below 10× should be REJECTED"
1307        );
1308    }
1309
1310    /// FALSIFY-CHINCHILLA-005 (unit): generously-provisioned ratios
1311    /// (≥ 10×) pass without --force flag.
1312    #[test]
1313    fn chinchilla_hard_gate_accepts_well_provisioned() {
1314        let cfg = qwen_05b_config();
1315        let n = estimate_param_count(&cfg);
1316        // 25·N = generous (above 20× compute-optimal target).
1317        let bs = 16u64;
1318        let sl = 512u64;
1319        let steps_25x = ((25 * n) / (bs * sl)) as usize;
1320        let verdict = chinchilla_gate_check(&cfg, steps_25x, bs as usize, sl as usize, false);
1321        assert!(verdict.is_none(), "25× should pass");
1322    }
1323
1324    #[test]
1325    fn preflight_accepts_matching_vocab() {
1326        // GATE-ARCH-370M-011 acceptance case: tokenizer vocab.json with
1327        // exactly Llama370MConfig::VOCAB_SIZE entries must pass pre-flight.
1328        let tmp = TempDir::new().expect("tempdir");
1329        stage_vocab_json(tmp.path(), Llama370MConfig::VOCAB_SIZE);
1330        preflight_tokenizer_vocab_matches_target(tmp.path(), Llama370MConfig::VOCAB_SIZE, false)
1331            .expect("matching vocab must pass GATE-ARCH-370M-011");
1332    }
1333
1334    #[test]
1335    fn preflight_rejects_tokenizer_vocab_mismatch() {
1336        // FALSIFY-ARCH-370M-011: a tokenizer whose vocab size drifts from
1337        // the model's pinned VOCAB_SIZE MUST abort dispatch with an error
1338        // message that names both values and the gate id, so the operator
1339        // can see the mismatch without stepping through code. Task #131
1340        // bumped VOCAB_SIZE to 50_257 (Option A) — the counter-example
1341        // below now exercises a tokenizer one token short of contract.
1342        let tmp = TempDir::new().expect("tempdir");
1343        let mismatch = Llama370MConfig::VOCAB_SIZE - 1;
1344        stage_vocab_json(tmp.path(), mismatch);
1345        let err = preflight_tokenizer_vocab_matches_target(
1346            tmp.path(),
1347            Llama370MConfig::VOCAB_SIZE,
1348            false,
1349        )
1350        .expect_err("tokenizer/model vocab mismatch must be rejected");
1351        match err {
1352            CliError::ValidationFailed(msg) => {
1353                assert!(
1354                    msg.contains("GATE-ARCH-370M-011"),
1355                    "msg must cite gate: {msg}"
1356                );
1357                assert!(
1358                    msg.contains(&mismatch.to_string()),
1359                    "msg must name tokenizer vocab: {msg}"
1360                );
1361                assert!(
1362                    msg.contains(&Llama370MConfig::VOCAB_SIZE.to_string()),
1363                    "msg must name model vocab: {msg}"
1364                );
1365            }
1366            other => panic!("unexpected error: {other:?}"),
1367        }
1368    }
1369
1370    #[test]
1371    fn preflight_rejects_missing_vocab_json() {
1372        // Missing vocab.json is a pre-flight failure (not a later shard
1373        // error) — the operator should know the tokenizer layout is
1374        // wrong, not that the dataset is empty.
1375        let tmp = TempDir::new().expect("tempdir");
1376        let err = preflight_tokenizer_vocab_matches_target(
1377            tmp.path(),
1378            Llama370MConfig::VOCAB_SIZE,
1379            false,
1380        )
1381        .expect_err("missing vocab.json must be rejected");
1382        match err {
1383            CliError::ValidationFailed(msg) => {
1384                assert!(
1385                    msg.contains("GATE-ARCH-370M-011"),
1386                    "msg must cite gate: {msg}"
1387                );
1388                assert!(
1389                    msg.contains("cannot read"),
1390                    "msg must name I/O failure: {msg}"
1391                );
1392            }
1393            other => panic!("unexpected error: {other:?}"),
1394        }
1395    }
1396
1397    /// FALSIFY-APR-PRETRAIN-ARCH-005 — a Qwen tokenizer (vocab=151_936) MUST
1398    /// pass preflight when the target_vocab_size is the Qwen extracted-arch
1399    /// (151_936). Falsifies a regression where preflight would still gate
1400    /// against the hardcoded Llama370M vocab.
1401    ///
1402    /// Spec: SPEC-SHIP-TWO-001 §50.4 step 5d.
1403    #[test]
1404    fn preflight_qwen_vocab_passes_with_qwen_target() {
1405        const QWEN2_VOCAB_SIZE: usize = 151_936;
1406        let tmp = TempDir::new().expect("tempdir");
1407        stage_vocab_json(tmp.path(), QWEN2_VOCAB_SIZE);
1408        // §50.4 step 5d called this with init=Some semantic (the polymorphic path). Use
1409        // init_is_some=true here per §55 relaxed-bound semantics; vocab.len() == target
1410        // is still acceptable under <=.
1411        preflight_tokenizer_vocab_matches_target(tmp.path(), QWEN2_VOCAB_SIZE, true).expect(
1412            "Qwen tokenizer (151_936) MUST pass preflight when target is Qwen-shaped — \
1413             this is the load-bearing claim of §49 fine-tune from a Qwen2.5 init checkpoint",
1414        );
1415    }
1416
1417    /// FALSIFY-APR-PRETRAIN-ARCH-006 — a Qwen tokenizer (vocab=151_936) MUST
1418    /// FAIL preflight when target_vocab_size is the Llama370M baseline
1419    /// (50_257). Falsifies the silent-pass class where an operator would
1420    /// accidentally pair a Qwen tokenizer with the from-scratch trainer.
1421    ///
1422    /// Spec: SPEC-SHIP-TWO-001 §50.4 step 5d.
1423    #[test]
1424    fn preflight_qwen_vocab_fails_with_llama_target() {
1425        const QWEN2_VOCAB_SIZE: usize = 151_936;
1426        let tmp = TempDir::new().expect("tempdir");
1427        stage_vocab_json(tmp.path(), QWEN2_VOCAB_SIZE);
1428        // §55: this is the from-scratch path (init absent), so init_is_some=false.
1429        // Strict equality applies; tokenizer (151_936) ≠ target (50_257) MUST fail.
1430        let err = preflight_tokenizer_vocab_matches_target(
1431            tmp.path(),
1432            Llama370MConfig::VOCAB_SIZE,
1433            false,
1434        )
1435        .expect_err(
1436            "Qwen tokenizer (151_936) MUST FAIL preflight when target is Llama370M (50_257) — \
1437             silent-pass would corrupt training",
1438        );
1439        match err {
1440            CliError::ValidationFailed(msg) => {
1441                assert!(
1442                    msg.contains(&QWEN2_VOCAB_SIZE.to_string()),
1443                    "msg must name Qwen vocab size 151_936: {msg}"
1444                );
1445                assert!(
1446                    msg.contains(&Llama370MConfig::VOCAB_SIZE.to_string()),
1447                    "msg must name target Llama vocab size 50_257: {msg}"
1448                );
1449            }
1450            other => panic!("unexpected error: {other:?}"),
1451        }
1452    }
1453
1454    /// FALSIFY-APR-PRETRAIN-ARCH-009 (§55) — at preflight level, an HF
1455    /// tokenizer with vocab.json count = 151665 (BPE+added, the §54 LIVE
1456    /// smoke shape) MUST PASS preflight when target is Qwen 151936 AND
1457    /// init_is_some=true (the polymorphic path).
1458    #[test]
1459    fn preflight_qwen_reserved_slots_pass_under_polymorphic_init() {
1460        const QWEN_TOKENIZER_EFFECTIVE: usize = 151_665;
1461        const QWEN_DECLARED_VOCAB: usize = 151_936;
1462        let tmp = TempDir::new().expect("tempdir");
1463        stage_vocab_json(tmp.path(), QWEN_TOKENIZER_EFFECTIVE);
1464
1465        // init_is_some=true: relaxed bound applies; 151665 ≤ 151936 PASSES.
1466        preflight_tokenizer_vocab_matches_target(tmp.path(), QWEN_DECLARED_VOCAB, true).expect(
1467            "FALSIFY-APR-PRETRAIN-ARCH-009: HF reserved-slot tokenizer (151_665 ≤ 151_936) \
1468             MUST pass preflight under polymorphic init path (§55 relaxed bound)",
1469        );
1470
1471        // init_is_some=false: strict equality applies; 151665 ≠ 151936 FAILS.
1472        let err = preflight_tokenizer_vocab_matches_target(tmp.path(), QWEN_DECLARED_VOCAB, false)
1473            .expect_err(
1474                "FALSIFY-APR-PRETRAIN-ARCH-009 dual: from-scratch path MUST keep strict ==",
1475            );
1476        match err {
1477            CliError::ValidationFailed(msg) => {
1478                assert!(
1479                    msg.contains("GATE-ARCH-370M-011")
1480                        && msg.contains(&QWEN_TOKENIZER_EFFECTIVE.to_string())
1481                        && msg.contains(&QWEN_DECLARED_VOCAB.to_string()),
1482                    "strict-mode error must name gate + both sizes: {msg}"
1483                );
1484            }
1485            other => panic!("unexpected error: {other:?}"),
1486        }
1487    }
1488
1489    /// FALSIFY-APR-PRETRAIN-ARCH-010 (§55) — at preflight level, a tokenizer
1490    /// with MORE entries than the model declares MUST FAIL even under the
1491    /// polymorphic init path. This is the OOB-safety guard: such a tokenizer
1492    /// could emit ids ≥ model_vocab → silent embedding-lookup garbage.
1493    #[test]
1494    fn preflight_oversized_tokenizer_rejected_even_under_polymorphic_init() {
1495        const QWEN_DECLARED_VOCAB: usize = 151_936;
1496        let oversized = QWEN_DECLARED_VOCAB + 100;
1497        let tmp = TempDir::new().expect("tempdir");
1498        stage_vocab_json(tmp.path(), oversized);
1499
1500        let err = preflight_tokenizer_vocab_matches_target(
1501            tmp.path(),
1502            QWEN_DECLARED_VOCAB,
1503            true, // polymorphic path
1504        )
1505        .expect_err(
1506            "FALSIFY-APR-PRETRAIN-ARCH-010: oversized tokenizer MUST fail-fast even under \
1507             polymorphic init (OOB safety; relaxed bound is ≤ not <)",
1508        );
1509        match err {
1510            CliError::ValidationFailed(msg) => {
1511                assert!(
1512                    msg.contains("RELAXED") && msg.contains("OOB"),
1513                    "polymorphic-mode error must cite RELAXED + OOB: {msg}"
1514                );
1515            }
1516            other => panic!("unexpected error: {other:?}"),
1517        }
1518    }
1519
1520    /// FALSIFY-APR-PRETRAIN-INIT-CUDA-001 (drift-prevention sentinel,
1521    /// post-5f.5): after §50.4 step 5f.5 SHIPPED, the const message
1522    /// pins the wireup-is-wired property. The string MUST contain
1523    /// (a) the falsifier id, (b) the canonical "is wired for --device
1524    /// cuda" phrase, (c) a reference to the symmetric builder
1525    /// `build_shared_cuda_trainer_with_init`, and (d) the "5f.5
1526    /// SHIPPED" status marker. If a future refactor accidentally
1527    /// reverts the wireup or renames the symmetric builder, this test
1528    /// catches the drift before the contract reference goes stale.
1529    ///
1530    /// Pinned via `pub(crate) const FALSIFY_APR_PRETRAIN_INIT_CUDA_001_MSG`
1531    /// so this test fires on a CPU-only build (no `--features cuda` needed).
1532    /// The const itself is NOT emitted by any code path in `drive_real`;
1533    /// it survives only to anchor the contract obligation. The runtime
1534    /// behaviour (`drive_real_cuda` calling `build_shared_cuda_trainer_with_init`
1535    /// when `init_arch.is_some() || init_path.is_some()`) is exercised
1536    /// at the entrenar crate level where CUDA-feature builds can fire it.
1537    #[test]
1538    fn drive_real_cuda_init_path_wireup_sentinel_pinned() {
1539        let msg = FALSIFY_APR_PRETRAIN_INIT_CUDA_001_MSG;
1540        assert!(
1541            msg.contains("FALSIFY-APR-PRETRAIN-INIT-CUDA-001"),
1542            "sentinel MUST cite the falsifier id (auditability): {msg}"
1543        );
1544        assert!(
1545            msg.contains("is wired for --device cuda"),
1546            "sentinel MUST contain the canonical 'is wired' phrase so \
1547             operators recognize §50.4 step 5f.5 SHIPPED: {msg}"
1548        );
1549        assert!(
1550            msg.contains("build_shared_cuda_trainer_with_init"),
1551            "sentinel MUST name the symmetric builder so future agents \
1552             know which symbol implements the wireup: {msg}"
1553        );
1554        assert!(
1555            msg.contains("5f.5 SHIPPED"),
1556            "sentinel MUST include the 5f.5 SHIPPED status marker so \
1557             grep over the codebase can find the discharge point: {msg}"
1558        );
1559    }
1560
1561    #[test]
1562    fn synthetic_pretrain_end_to_end_happy_path() {
1563        let tmp = TempDir::new().expect("tempdir");
1564        let dataset = tmp.path().join("data.jsonl");
1565        let tokenizer = tmp.path().join("tok");
1566        let run_dir = tmp.path().join("run");
1567
1568        let result = run(
1569            &dataset,
1570            &tokenizer,
1571            &run_dir,
1572            PretrainMode::Finetune,
1573            Some(5.0e-5),
1574            25,
1575            Some(5),
1576            2,
1577            4,
1578            5,
1579            42,
1580            Some(2.2),
1581            50257,
1582            true,
1583            "cpu",
1584            None,
1585            false,
1586            None,
1587            true,
1588        );
1589        assert!(
1590            result.is_ok(),
1591            "synthetic pretrain end-to-end must succeed: got {result:?}"
1592        );
1593    }
1594
1595    #[test]
1596    fn real_mode_empty_dataset_dir_errors() {
1597        // When --synthetic is off, the real-corpus branch must surface a
1598        // clear error if the dataset directory has no .bin shards. This
1599        // supersedes the old "non-synthetic is not implemented" guard.
1600        // Stage a valid vocab.json first so GATE-ARCH-370M-011 pre-flight
1601        // passes — otherwise the shard-iterator error below is never reached.
1602        let tmp = TempDir::new().expect("tempdir");
1603        let tok_dir = tmp.path().join("tok");
1604        stage_vocab_json(&tok_dir, Llama370MConfig::VOCAB_SIZE);
1605        let err = run(
1606            tmp.path(),
1607            &tok_dir,
1608            tmp.path(),
1609            PretrainMode::Finetune,
1610            Some(5.0e-5),
1611            10,
1612            Some(2),
1613            2,
1614            4,
1615            5,
1616            42,
1617            Some(2.2),
1618            50257,
1619            false,
1620            "cpu",
1621            None,
1622            false,
1623            None,
1624            true,
1625        )
1626        .expect_err("empty dataset dir must fail to initialise the shard iterator");
1627        match err {
1628            CliError::ValidationFailed(msg) => {
1629                assert!(
1630                    msg.contains("shard iterator init failed"),
1631                    "unexpected message: {msg}"
1632                );
1633            }
1634            other => panic!("unexpected error: {other:?}"),
1635        }
1636    }
1637
1638    #[test]
1639    fn invalid_target_val_loss_rejected() {
1640        let tmp = TempDir::new().expect("tempdir");
1641        let err = run(
1642            tmp.path(),
1643            tmp.path(),
1644            tmp.path(),
1645            PretrainMode::Finetune,
1646            Some(5.0e-5),
1647            10,
1648            Some(2),
1649            2,
1650            4,
1651            5,
1652            42,
1653            Some(-1.0),
1654            50257,
1655            true,
1656            "cpu",
1657            None,
1658            false,
1659            None,
1660            true,
1661        )
1662        .expect_err("negative target_val_loss must be rejected");
1663        assert!(matches!(err, CliError::ValidationFailed(_)));
1664    }
1665
1666    // ── GATE-TRAIN-009 / INV-TRAIN-009 falsifiers ──────────────────────
1667    // Contract: training-loop-pretrain-v1 v1.3.0 §hyperparameter_defaults
1668    //
1669    // These tests bind the CLI's `mode_defaults` resolver to the
1670    // hyperparameter_defaults YAML table. If the table is ever edited
1671    // without also updating this resolver (or vice versa), the tests
1672    // fail. That is exactly the drift INV-TRAIN-009 forbids.
1673
1674    #[test]
1675    fn mode_finetune_is_default_and_matches_contract() {
1676        // No overrides → resolved HP matches the `finetune` YAML row
1677        // (lr_max=5e-5, warmup_steps=100, target_val_loss=2.2) AND the
1678        // regime is Finetune so INV-TRAIN-005 epoch-zero cap = 10.0.
1679        let hp = mode_defaults(PretrainMode::Finetune, 50257, None, None, None);
1680        assert_eq!(hp.regime, TrainingRegime::Finetune);
1681        assert!(
1682            (hp.lr_max - 5.0e-5).abs() < 1.0e-12,
1683            "lr_max={} must equal finetune default 5e-5",
1684            hp.lr_max
1685        );
1686        assert_eq!(hp.warmup_steps, 100);
1687        assert!(
1688            (hp.target_val_loss - 2.2).abs() < 1.0e-6,
1689            "target_val_loss={} must equal finetune default 2.2",
1690            hp.target_val_loss
1691        );
1692    }
1693
1694    #[test]
1695    fn mode_from_scratch_applies_all_four_defaults() {
1696        // `--mode from-scratch` with no HP overrides MUST yield the full
1697        // cold-start 4-tuple atomically — regime=FromScratch, lr=3e-4,
1698        // warmup=1000, target=3.0. INV-TRAIN-009 falsifier (a).
1699        let hp = mode_defaults(PretrainMode::FromScratch, 50257, None, None, None);
1700        assert_eq!(hp.regime, TrainingRegime::FromScratch { vocab_size: 50257 });
1701        assert!(
1702            (hp.lr_max - 3.0e-4).abs() < 1.0e-12,
1703            "lr_max={} must equal from_scratch default 3e-4",
1704            hp.lr_max
1705        );
1706        assert_eq!(hp.warmup_steps, 1000);
1707        assert!(
1708            (hp.target_val_loss - 3.0).abs() < 1.0e-6,
1709            "target_val_loss={} must equal from_scratch default 3.0",
1710            hp.target_val_loss
1711        );
1712    }
1713
1714    #[test]
1715    fn mode_from_scratch_honors_explicit_lr_override() {
1716        // `--mode from-scratch --lr 1e-4` → regime still flips to
1717        // FromScratch AND warmup/target keep the from_scratch defaults,
1718        // but lr_max is the operator-supplied 1e-4. INV-TRAIN-009
1719        // falsifier (b): overrides win, regime still moves.
1720        let hp = mode_defaults(PretrainMode::FromScratch, 50257, Some(1.0e-4), None, None);
1721        assert_eq!(hp.regime, TrainingRegime::FromScratch { vocab_size: 50257 });
1722        assert!(
1723            (hp.lr_max - 1.0e-4).abs() < 1.0e-12,
1724            "lr_max={} must equal explicit override 1e-4",
1725            hp.lr_max
1726        );
1727        // Remaining two fields retained their mode defaults.
1728        assert_eq!(hp.warmup_steps, 1000);
1729        assert!((hp.target_val_loss - 3.0).abs() < 1.0e-6);
1730    }
1731
1732    // ── GATE-TRAIN-010 / INV-TRAIN-010 falsifiers ──────────────────────
1733    // Contract: training-loop-pretrain-v1 v1.4.0 §INV-TRAIN-010
1734    //
1735    // Task #105's original wiring shipped `synthetic: bool` with
1736    // `default_value = "true"`. The `--synthetic` flag had no
1737    // companion to turn it off, so every invocation of `apr pretrain`
1738    // silently routed to drive_synthetic. Tasks #119 / #124 / #125
1739    // all captured scripted-loss output and mis-labeled it real
1740    // compute. These two tests parse actual argv through clap and
1741    // assert the routing discriminator byte-for-byte.
1742
1743    fn parse_pretrain_synthetic(extra: &[&str]) -> bool {
1744        // The `Commands` enum is large enough in debug builds to overflow
1745        // the default 2 MiB test-thread stack during clap's recursive
1746        // destructuring. Run the parse on a worker thread with a 16 MiB
1747        // stack so this falsifier passes in both debug and release.
1748        let extra: Vec<String> = extra.iter().map(|s| (*s).to_string()).collect();
1749        std::thread::Builder::new()
1750            .stack_size(16 * 1024 * 1024)
1751            .spawn(move || {
1752                use clap::Parser;
1753                let mut argv: Vec<String> = vec![
1754                    "apr".to_string(),
1755                    "pretrain".to_string(),
1756                    "--dataset".to_string(),
1757                    "/tmp/_gate_train_010/ds".to_string(),
1758                    "--tokenizer".to_string(),
1759                    "/tmp/_gate_train_010/tok".to_string(),
1760                    "--run-dir".to_string(),
1761                    "/tmp/_gate_train_010/run".to_string(),
1762                ];
1763                argv.extend(extra);
1764                let cli = crate::Cli::try_parse_from(&argv).expect("clap parse must succeed");
1765                match *cli.command {
1766                    crate::Commands::Extended(crate::ExtendedCommands::Pretrain {
1767                        synthetic,
1768                        ..
1769                    }) => synthetic,
1770                    other => panic!("expected ExtendedCommands::Pretrain, got {other:?}"),
1771                }
1772            })
1773            .expect("spawn parse thread")
1774            .join()
1775            .expect("parse thread must not panic")
1776    }
1777
1778    #[test]
1779    fn cli_pretrain_defaults_to_real_compute() {
1780        // Absent `--synthetic` MUST parse to synthetic=false so the
1781        // dispatcher routes through drive_real.
1782        assert!(
1783            !parse_pretrain_synthetic(&[]),
1784            "INV-TRAIN-010: `apr pretrain` (no --synthetic) must parse to synthetic=false"
1785        );
1786    }
1787
1788    #[test]
1789    fn cli_pretrain_synthetic_flag_routes_to_synthetic() {
1790        // `--synthetic` present MUST parse to synthetic=true.
1791        assert!(
1792            parse_pretrain_synthetic(&["--synthetic"]),
1793            "INV-TRAIN-010: `apr pretrain --synthetic` must parse to synthetic=true"
1794        );
1795    }
1796
1797    // ── FALSIFY-GPUTRAIN-001 / 002 CLI surface (contract phase 1) ────
1798    // Contract: gpu-training-backend-v1 §device_dispatch
1799    //
1800    // These tests parse actual `apr pretrain --device …` argv through
1801    // clap and assert the string is surfaced byte-for-byte to the
1802    // dispatcher. `resolve_device()` itself is exercised by
1803    // `aprender-train::train::device::tests` — these tests verify that
1804    // the CLI flag exists and that its default is `auto` (the only
1805    // spec allowed to fall back).
1806
1807    fn parse_pretrain_device(extra: &[&str]) -> String {
1808        let extra: Vec<String> = extra.iter().map(|s| (*s).to_string()).collect();
1809        std::thread::Builder::new()
1810            .stack_size(16 * 1024 * 1024)
1811            .spawn(move || {
1812                use clap::Parser;
1813                let mut argv: Vec<String> = vec![
1814                    "apr".to_string(),
1815                    "pretrain".to_string(),
1816                    "--dataset".to_string(),
1817                    "/tmp/_gputrain_device/ds".to_string(),
1818                    "--tokenizer".to_string(),
1819                    "/tmp/_gputrain_device/tok".to_string(),
1820                    "--run-dir".to_string(),
1821                    "/tmp/_gputrain_device/run".to_string(),
1822                ];
1823                argv.extend(extra);
1824                let cli = crate::Cli::try_parse_from(&argv).expect("clap parse must succeed");
1825                match *cli.command {
1826                    crate::Commands::Extended(crate::ExtendedCommands::Pretrain {
1827                        device, ..
1828                    }) => device,
1829                    other => panic!("expected ExtendedCommands::Pretrain, got {other:?}"),
1830                }
1831            })
1832            .expect("spawn parse thread")
1833            .join()
1834            .expect("parse thread must not panic")
1835    }
1836
1837    #[test]
1838    fn cli_pretrain_device_defaults_to_auto() {
1839        // Absent `--device`, the flag MUST parse to `"auto"` — the only
1840        // spec allowed to silently fall back to CPU when CUDA is not
1841        // available. Any other default would violate the contract's
1842        // "explicit request → hard-fail" invariant.
1843        assert_eq!(
1844            parse_pretrain_device(&[]),
1845            "auto",
1846            "gpu-training-backend-v1 INV-GPUTRAIN-002: default --device must be `auto`",
1847        );
1848    }
1849
1850    #[test]
1851    fn cli_pretrain_device_accepts_cpu() {
1852        // `--device cpu` MUST round-trip through clap unchanged.
1853        assert_eq!(parse_pretrain_device(&["--device", "cpu"]), "cpu");
1854    }
1855
1856    #[test]
1857    fn cli_pretrain_device_accepts_cuda_index() {
1858        // `--device cuda:7` MUST round-trip unchanged; grammar
1859        // enforcement happens in `resolve_device`, not at clap.
1860        assert_eq!(parse_pretrain_device(&["--device", "cuda:7"]), "cuda:7");
1861    }
1862
1863    // ── apr-pretrain-from-init-v1 falsifiers ────────────────────────────
1864    // Contract: contracts/apr-pretrain-from-init-v1.yaml v1.0.0 PROPOSED
1865    // Spec: SPEC-SHIP-TWO-001 §49 step 4 — wire `apr pretrain --init`
1866    //
1867    // PARTIAL_ALGORITHM_LEVEL: file-existence + magic-byte checks bind
1868    // FALSIFY-APR-PRETRAIN-INIT-003 / -004; the clap surface binds
1869    // FALSIFY-001 / -007. FALSIFY-005 (arch mismatch), -006 (init_loss
1870    // signal), -009 (optimizer state), -010 (idempotent load) are gated
1871    // on the §49 step 5 weight-load impl. The "valid APR returns
1872    // not-yet-wired" test pins the no-silent-fallback contract: a
1873    // recognised APR cannot be silently ignored.
1874
1875    fn parse_pretrain_init(extra: &[&str]) -> Option<std::path::PathBuf> {
1876        let extra: Vec<String> = extra.iter().map(|s| (*s).to_string()).collect();
1877        std::thread::Builder::new()
1878            .stack_size(16 * 1024 * 1024)
1879            .spawn(move || {
1880                use clap::Parser;
1881                let mut argv: Vec<String> = vec![
1882                    "apr".to_string(),
1883                    "pretrain".to_string(),
1884                    "--dataset".to_string(),
1885                    "/tmp/_init_flag/ds".to_string(),
1886                    "--tokenizer".to_string(),
1887                    "/tmp/_init_flag/tok".to_string(),
1888                    "--run-dir".to_string(),
1889                    "/tmp/_init_flag/run".to_string(),
1890                ];
1891                argv.extend(extra);
1892                let cli = crate::Cli::try_parse_from(&argv).expect("clap parse must succeed");
1893                match *cli.command {
1894                    crate::Commands::Extended(crate::ExtendedCommands::Pretrain {
1895                        init, ..
1896                    }) => init,
1897                    other => panic!("expected ExtendedCommands::Pretrain, got {other:?}"),
1898                }
1899            })
1900            .expect("spawn parse thread")
1901            .join()
1902            .expect("parse thread must not panic")
1903    }
1904
1905    /// FALSIFY-APR-PRETRAIN-INIT-001: --init flag exists in clap surface.
1906    #[test]
1907    fn pretrain_init_flag_absent_parses_to_none() {
1908        // Absent --init MUST parse to None. Falsifies a regression where a
1909        // default value silently injects a path the operator never typed.
1910        assert_eq!(
1911            parse_pretrain_init(&[]),
1912            None,
1913            "FALSIFY-APR-PRETRAIN-INIT-001/002: default --init must be None (no silent default)"
1914        );
1915    }
1916
1917    /// FALSIFY-APR-PRETRAIN-INIT-001: --init <PATH> parses to Some(PathBuf).
1918    #[test]
1919    fn pretrain_init_flag_parses_path() {
1920        let parsed = parse_pretrain_init(&["--init", "/tmp/foo.apr"]);
1921        assert_eq!(
1922            parsed.as_deref().and_then(|p| p.to_str()),
1923            Some("/tmp/foo.apr"),
1924            "FALSIFY-APR-PRETRAIN-INIT-001: --init <PATH> must round-trip through clap"
1925        );
1926    }
1927
1928    /// FALSIFY-APR-PRETRAIN-INIT-003: --init <missing-file> fails fast
1929    /// before any trainer allocation; stderr names the path.
1930    #[test]
1931    fn pretrain_init_missing_file_errors() {
1932        let tmp = TempDir::new().expect("tempdir");
1933        let missing = tmp.path().join("does-not-exist.apr");
1934        let err = run(
1935            tmp.path(),
1936            tmp.path(),
1937            tmp.path(),
1938            PretrainMode::Finetune,
1939            Some(5.0e-5),
1940            10,
1941            Some(2),
1942            2,
1943            4,
1944            5,
1945            42,
1946            Some(2.2),
1947            50257,
1948            true,
1949            "cpu",
1950            Some(&missing),
1951            false,
1952            None,
1953            true,
1954        )
1955        .expect_err("missing --init file must be rejected");
1956        match err {
1957            CliError::ValidationFailed(msg) => {
1958                assert!(
1959                    msg.contains("FALSIFY-APR-PRETRAIN-INIT-003"),
1960                    "msg must cite falsifier id: {msg}"
1961                );
1962                assert!(
1963                    msg.contains("does-not-exist.apr"),
1964                    "msg must name the missing path: {msg}"
1965                );
1966            }
1967            other => panic!("unexpected error: {other:?}"),
1968        }
1969    }
1970
1971    /// FALSIFY-APR-PRETRAIN-INIT-004: --init with wrong magic bytes fails fast.
1972    #[test]
1973    fn pretrain_init_bad_magic_errors() {
1974        let tmp = TempDir::new().expect("tempdir");
1975        let bad = tmp.path().join("not-an-apr.bin");
1976        std::fs::write(&bad, b"GGUF\x00\x00\x00\x00\x00\x00\x00\x00").expect("write fixture file");
1977        let err = run(
1978            tmp.path(),
1979            tmp.path(),
1980            tmp.path(),
1981            PretrainMode::Finetune,
1982            Some(5.0e-5),
1983            10,
1984            Some(2),
1985            2,
1986            4,
1987            5,
1988            42,
1989            Some(2.2),
1990            50257,
1991            true,
1992            "cpu",
1993            Some(&bad),
1994            false,
1995            None,
1996            true,
1997        )
1998        .expect_err("invalid magic bytes must be rejected");
1999        match err {
2000            CliError::ValidationFailed(msg) => {
2001                assert!(
2002                    msg.contains("FALSIFY-APR-PRETRAIN-INIT-004"),
2003                    "msg must cite falsifier id: {msg}"
2004                );
2005                assert!(
2006                    msg.contains("not a valid APR file"),
2007                    "msg must describe magic mismatch: {msg}"
2008                );
2009            }
2010            other => panic!("unexpected error: {other:?}"),
2011        }
2012    }
2013
2014    /// FALSIFY-APR-PRETRAIN-INIT-004: empty file (read_exact fails on 4 bytes).
2015    #[test]
2016    fn pretrain_init_empty_file_errors() {
2017        let tmp = TempDir::new().expect("tempdir");
2018        let empty = tmp.path().join("empty.apr");
2019        std::fs::write(&empty, b"").expect("write empty fixture");
2020        let err = run(
2021            tmp.path(),
2022            tmp.path(),
2023            tmp.path(),
2024            PretrainMode::Finetune,
2025            Some(5.0e-5),
2026            10,
2027            Some(2),
2028            2,
2029            4,
2030            5,
2031            42,
2032            Some(2.2),
2033            50257,
2034            true,
2035            "cpu",
2036            Some(&empty),
2037            false,
2038            None,
2039            true,
2040        )
2041        .expect_err("empty file must be rejected (cannot contain magic bytes)");
2042        assert!(matches!(err, CliError::ValidationFailed(_)));
2043    }
2044
2045    /// §50.4 step 5f.4: a magic-byte-valid but metadata-bogus APR file
2046    /// MUST be rejected at the architecture-extraction step, not silently
2047    /// fall back to random init. The error must clearly cite the
2048    /// architecture-extraction failure (not the legacy "not yet wired"
2049    /// guard, which was retired when the wireup landed). This drift-prevention
2050    /// pins the new fail-closed semantic.
2051    #[test]
2052    fn pretrain_init_valid_magic_but_bogus_metadata_fails_at_arch_extraction() {
2053        let tmp = TempDir::new().expect("tempdir");
2054        let valid = tmp.path().join("v2-valid-magic-bogus-metadata.apr");
2055        // APR\0 magic + padding; passes validate_init_apr_path but
2056        // read_apr_architecture (which reads the v2 header) will return None.
2057        std::fs::write(&valid, b"APR\x00\x00\x00\x00\x00\x00\x00\x00\x00")
2058            .expect("write fixture file");
2059        let err = run(
2060            tmp.path(),
2061            tmp.path(),
2062            tmp.path(),
2063            PretrainMode::Finetune,
2064            Some(5.0e-5),
2065            10,
2066            Some(2),
2067            2,
2068            4,
2069            5,
2070            42,
2071            Some(2.2),
2072            50257,
2073            true,
2074            "cpu",
2075            Some(&valid),
2076            false,
2077            None,
2078            true,
2079        )
2080        .expect_err("bogus metadata must NOT silently random-init");
2081        match err {
2082            CliError::ValidationFailed(msg) => {
2083                assert!(
2084                    !msg.contains("not yet wired"),
2085                    "the legacy step-5-partial guard must be retired: {msg}"
2086                );
2087                // The actual error from read_apr_architecture failure or
2088                // downstream layer; both are acceptable as long as we DON'T
2089                // silently load random init.
2090            }
2091            other => panic!("unexpected error: {other:?}"),
2092        }
2093    }
2094
2095    /// Pin v1 magic (APRN) acceptance — `validate_init_apr_path` alone
2096    /// (decoupled from architecture extraction) returns Ok for both APR\0
2097    /// and APRN magic bytes. Architecture extraction is a separate step.
2098    #[test]
2099    fn pretrain_init_v1_magic_aprn_passes_validate_init_apr_path() {
2100        let tmp = TempDir::new().expect("tempdir");
2101        let v1 = tmp.path().join("v1-aprn.apr");
2102        std::fs::write(&v1, b"APRN\x00\x00\x00\x00").expect("write fixture file");
2103        let result = validate_init_apr_path(&v1);
2104        assert!(
2105            result.is_ok(),
2106            "APRN magic must pass validate_init_apr_path; got {result:?}"
2107        );
2108    }
2109
2110    // ── PMAT-125 B1: additional CPU-only coverage ──────────────────────────
2111
2112    /// `estimate_param_count` must not panic on zero/degenerate dims — it uses
2113    /// saturating arithmetic throughout, so an all-zero arch returns 0.
2114    #[test]
2115    fn estimate_param_count_zero_dims_is_saturating() {
2116        let mut cfg = TransformerConfig::llama2_7b();
2117        cfg.vocab_size = 0;
2118        cfg.hidden_size = 0;
2119        cfg.intermediate_size = 0;
2120        cfg.num_hidden_layers = 0;
2121        assert_eq!(estimate_param_count(&cfg), 0);
2122    }
2123
2124    /// `estimate_param_count` counts the embedding term `vocab × hidden` once
2125    /// even with zero layers (no per-layer contribution).
2126    #[test]
2127    fn estimate_param_count_zero_layers_is_embedding_plus_norm() {
2128        let mut cfg = TransformerConfig::llama2_7b();
2129        cfg.vocab_size = 100;
2130        cfg.hidden_size = 8;
2131        cfg.intermediate_size = 32;
2132        cfg.num_hidden_layers = 0;
2133        // embed = 100*8 = 800; + final norm (hidden) = 8 → 808.
2134        assert_eq!(estimate_param_count(&cfg), 808);
2135    }
2136
2137    /// `checkpoint_name_and_arch` honors `hf_model_type` for the name suffix
2138    /// while falling back to `LlamaForCausalLM` when `hf_architecture` is unset.
2139    #[test]
2140    fn checkpoint_name_and_arch_model_type_without_architecture() {
2141        let mut cfg = TransformerConfig::llama2_7b();
2142        cfg.hf_model_type = Some("gpt2".to_string());
2143        cfg.hf_architecture = None;
2144        let (name, arch) = checkpoint_name_and_arch(Some(&cfg));
2145        assert_eq!(name, "gpt2-pretrain");
2146        assert_eq!(arch, "LlamaForCausalLM");
2147    }
2148
2149    /// `validate_init_apr_path` on a directory (not a file) fails fast with a
2150    /// FALSIFY-tagged validation error rather than panicking.
2151    #[test]
2152    fn validate_init_apr_path_directory_errors() {
2153        let tmp = TempDir::new().expect("tempdir");
2154        let err = validate_init_apr_path(tmp.path()).unwrap_err();
2155        assert!(matches!(err, CliError::ValidationFailed(_)));
2156    }
2157
2158    /// `validate_init_apr_path` on a 3-byte file (too short for the 4-byte
2159    /// magic) surfaces the INIT-004 short-file error.
2160    #[test]
2161    fn validate_init_apr_path_three_bytes_too_short() {
2162        let tmp = TempDir::new().expect("tempdir");
2163        let p = tmp.path().join("short.apr");
2164        std::fs::write(&p, b"APR").expect("write");
2165        let err = validate_init_apr_path(&p).unwrap_err();
2166        match err {
2167            CliError::ValidationFailed(m) => assert!(m.contains("INIT-004")),
2168            other => panic!("unexpected: {other:?}"),
2169        }
2170    }
2171}