1use 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
36const HELD_OUT_BATCHES: usize = 16;
49
50pub(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#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
78pub enum PretrainMode {
79 Finetune,
81 FromScratch,
83}
84
85#[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
97fn 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 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
132fn 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#[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 let resolved_device =
209 resolve_device(device).map_err(|e| CliError::ValidationFailed(e.to_string()))?;
210
211 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 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 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 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 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_epochs: 5,
352 min_epochs_before_early_stop: 3,
358 regime: hp.regime,
359 };
360
361 if !json_output {
362 print_header(&config);
363 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 match status {
398 RunStatus::Aborted(abort) => Err(abort_to_err(&abort)),
399 RunStatus::Ok { .. } | RunStatus::EarlyStop { .. } => Ok(()),
400 }
401}
402
403fn 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 run_and_report(config, step_fn, val_fn, None, json_output)
428}
429
430fn 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 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
468fn 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 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#[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 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 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 .with_warn_on_wrap_around(true);
583
584 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 .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 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 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#[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 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#[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 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 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#[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
821fn 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 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
882pub(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 target_val_loss: f32,
969 converged: Option<bool>,
974 epochs_completed: usize,
975 steps_recorded: usize,
976 val_loss_history: Vec<f32>,
977 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 #[test]
1048 fn missing_the_target_is_not_convergence() {
1049 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 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 let status = RunStatus::Aborted(PretrainAbort::DivergenceAtEpochZero { val_loss: 18.0 });
1117 assert_eq!(reached_target(&status, 2.2), None);
1118 }
1119
1120 #[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 #[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 #[test]
1148 fn checkpoint_name_and_arch_init_without_hf_fields() {
1149 let cfg = TransformerConfig::llama2_7b();
1150 let (name, arch) = checkpoint_name_and_arch(Some(&cfg));
1152 assert_eq!(name, "model-pretrain");
1153 assert_eq!(arch, "LlamaForCausalLM");
1154 }
1155
1156 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 #[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 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 #[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 assert!(
1206 n24 > n1 * 4,
1207 "24-layer model {n24} should be at least 4× 1-layer model {n1}",
1208 );
1209 }
1210
1211 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 #[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 #[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 #[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 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 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 #[test]
1313 fn chinchilla_hard_gate_accepts_well_provisioned() {
1314 let cfg = qwen_05b_config();
1315 let n = estimate_param_count(&cfg);
1316 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 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 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 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 #[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 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 #[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 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 #[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 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 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 #[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, )
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 #[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 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 #[test]
1675 fn mode_finetune_is_default_and_matches_contract() {
1676 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 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 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 assert_eq!(hp.warmup_steps, 1000);
1729 assert!((hp.target_val_loss - 3.0).abs() < 1.0e-6);
1730 }
1731
1732 fn parse_pretrain_synthetic(extra: &[&str]) -> bool {
1744 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 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 assert!(
1792 parse_pretrain_synthetic(&["--synthetic"]),
1793 "INV-TRAIN-010: `apr pretrain --synthetic` must parse to synthetic=true"
1794 );
1795 }
1796
1797 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 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 assert_eq!(parse_pretrain_device(&["--device", "cpu"]), "cpu");
1854 }
1855
1856 #[test]
1857 fn cli_pretrain_device_accepts_cuda_index() {
1858 assert_eq!(parse_pretrain_device(&["--device", "cuda:7"]), "cuda:7");
1861 }
1862
1863 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 #[test]
1907 fn pretrain_init_flag_absent_parses_to_none() {
1908 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 #[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 #[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 #[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 #[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 #[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 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 }
2091 other => panic!("unexpected error: {other:?}"),
2092 }
2093 }
2094
2095 #[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 #[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 #[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 assert_eq!(estimate_param_count(&cfg), 808);
2135 }
2136
2137 #[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 #[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 #[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}