From 9dfce204bf5190f09c5cab99760dae6f563e8d87 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 03:46:44 +0000 Subject: [PATCH] Lot 28 : tests contre les mutations survivantes de l'optimiseur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 105 mutations du verrou et du job ; le premier passage en tuait 39. Les survivantes le sont désormais par des tests ajoutés : - l'essai noté dans le verrou pendant la mesure, la sauvegarde notée avant d'écrire, l'application refusée pendant la mesure, la cible inconnue ; - l'application gardée : preset qui n'est plus en service, API externe, clé changée ailleurs pendant l'application (défaite) ; - l'application planifiée : clé sur option refusée sans l'option ; - l'essai muet (délai de chargement), /props sans default_generation_settings, SIDE_SLOT sur un seul slot, threads comptés sur les cœurs de la sonde, aucun changement de moteur signalé quand son build est inconnu ; - VRAM mesurée par un faux nvidia-smi, budget, profondeur, notes et poids, écarts de mesure, essais écartés, moteur piloté par la façade. nvidia-smi, la pause entre deux lectures de VRAM et l'unité du budget deviennent réglables (tests). Le test de l'essai orphelin lit l'identité du processus une fois l'exec visible dans /proc : lue trop tôt, elle portait l'exécutable du test (échec intermittent en CI). Les vingt survivantes restantes, équivalentes ou hors de portée d'un test (courses, estimation seulement), sont décrites dans la spec. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- ...-rust-migration-lot28-optimiseur-design.md | 81 +++ rust/loki-rs/src/tune/job.rs | 120 ++- rust/loki-rs/src/tune/lock.rs | 15 +- rust/loki-rs/src/tune/tests.rs | 106 ++- rust/loki-rs/tests/common/fakego.rs | 6 + rust/loki-rs/tests/tune.rs | 687 +++++++++++++++++- 6 files changed, 963 insertions(+), 52 deletions(-) diff --git a/docs/superpowers/specs/2026-10-16-rust-migration-lot28-optimiseur-design.md b/docs/superpowers/specs/2026-10-16-rust-migration-lot28-optimiseur-design.md index 80d8a28..057f3b6 100644 --- a/docs/superpowers/specs/2026-10-16-rust-migration-lot28-optimiseur-design.md +++ b/docs/superpowers/specs/2026-10-16-rust-migration-lot28-optimiseur-design.md @@ -173,6 +173,87 @@ Côté Go, `TestFacadeTune` vérifie : - le contexte : au repos, pendant l'optimisation, pendant un bench ; - le démarrage et l'arrêt sans le refus de l'optimiseur. +## Mutations + +105 mutations du port Rust (verrou et job). Le premier passage en a tué 39. +Les survivantes l'ont été ensuite par les tests ajoutés : + +- `tune/tests.rs` : + - un verrou sans PID ; + - l'identité d'un processus (démarrage noté, binaire admis) ; + - l'application planifiée : clé sur option refusée sans l'option, valeur + vide qui retire la clé ; +- `tune/job.rs` : l'ETA du suivi, l'arrondi à la minute, le numéro de build ; +- `tests/tune.rs` : + - **une optimisation entière** : + - l'essai noté dans le verrou pendant la mesure ; + - l'application refusée pendant la mesure ; + - la progression, les confirmations, le gain ; + - aucun changement de moteur signalé quand son build est inconnu ; + - la cible inconnue ; + - **l'application gardée** : + - preset qui n'est plus celui en service, API externe ; + - clé changée ailleurs pendant l'application (défaite) ; + - sauvegarde notée dans le verrou avant d'écrire ; + - **les essais** : + - écartés (ligne dénaturée, doublon, contexte perdu) ; + - muets (délai de chargement) ; + - **la mesure** : + - VRAM mesurée par un faux nvidia-smi ; + - budget atteint, profondeur commune ; + - notes et poids tirés de la télémétrie ; + - écarts de mesure (sous le seuil, nul, decode plus lent) ; + - **le moteur** : + - `/props` sans `default_generation_settings` ; + - SIDE_SLOT sur un seul slot ; + - threads comptés sur les cœurs de la sonde ; + - piloté par la façade (LOKI_ENGINE=rust) ; + - **les refus** : + - un second lancement pendant la prise du bail ; + - une application refusée sans journal ; + - hors phase d'application, un verrou périmé n'est pas défait. + +Une mutation du premier passage ne changeait rien (`plan_apply(…).and(Ok(()))` +garde l'erreur) : elle est remplacée par une qui retire vraiment le contrôle, +et tuée. + +Restent vingt équivalentes, ou hors de portée d'un test : + +- **le verrou** : + - le fichier retiré quand son écriture échoue après sa création (disque + plein), que les tests ne provoquent pas ; + - deux re-vérifications du ménage, contre un propriétaire qui revient + entre deux lectures : courses seulement ; + - la réécriture neutre (essai, moteur actif, sauvegarde effacés) avant le + retrait du verrou : sous Unix le retrait suit aussitôt ; elle ne compte + que si le retrait échoue ; +- **le suivi** : + - le résultat montré hors mesure : il est effacé au départ d'une mesure ; + - l'annulation n'est posée que pendant une mesure ; + - la borne de 6 h du budget, la durée d'un doublon, les étapes restantes : + l'estimation seulement ; + - l'annulation vérifiée entre deux essais et à la fin : l'essai en cours + l'a déjà dite (course) ; +- **la mesure** : + - VRAM nulle et nvidia-smi absent vont ensemble ; + - les candidats générés passent toujours `cfg_check` (garde contre une + génération fautive) ; + - la fin d'un essai constatée avant d'attendre : un réveil perdu, quand + l'essai meurt entre deux attentes (course) ; + - l'essai effacé du verrou après son arrêt : le suivant le remplace, ou le + verrou est rendu ; laissé, un Loki redémarré vérifie son identité avant + tout arrêt ; +- **l'application** : + - une valeur vide gardée dans la configuration gagnante : `cfg_check` et la + réécriture la traitent en clé absente ; + - la configuration active comparée à celle de la mesure : les empreintes + du preset et de la configuration sont prises ensemble, sur le preset dont + l'empreinte est celle de la configuration ; les deux contrôles qui + précèdent (preset inchangé, preset en service) l'impliquent ; + - le preset « non appliqué » : la configuration a changé entre le contrôle + et l'écriture (course) ; le contrôle suivant défait aussi, avec un autre + message. + ## Écarts assumés - Une sortie à blanc qui échoue est lue dans un fichier où vont les deux diff --git a/rust/loki-rs/src/tune/job.rs b/rust/loki-rs/src/tune/job.rs index df7bd36..016f891 100644 --- a/rust/loki-rs/src/tune/job.rs +++ b/rust/loki-rs/src/tune/job.rs @@ -304,6 +304,12 @@ pub struct Tune { pub trial_env: Vec<(String, String)>, /// Durée du bail du moteur (renouvelé au tiers). pub lease_ttl: Duration, + /// nvidia-smi (un faux dans les tests). + pub nvidia_smi: String, + /// Entre deux lectures du bilan de VRAM (500 ms). + pub gpu_pause: Duration, + /// Unité de `budget_min` (une minute ; plus court dans les tests). + pub budget_unit: Duration, } impl Tune { @@ -328,9 +334,33 @@ impl Tune { settle: Duration::from_secs(2), trial_env: Vec::new(), lease_ttl: LEASE_TTL, + nvidia_smi: "nvidia-smi".into(), + gpu_pause: Duration::from_millis(500), + budget_unit: Duration::from_secs(60), } } + /// `gpuStats` : lecture FRAÎCHE, vide sans nvidia-smi. + async fn gpus(&self) -> Vec { + crate::sysinfo::nvidia_smi_query_bin(&self.nvidia_smi) + .await + .map(|o| crate::sysinfo::parse_nvidia_smi(&String::from_utf8_lossy(&o))) + .unwrap_or_default() + } + + /// `gpuUsedSettled`. + async fn gpu_settled(&self, before: i64) -> i64 { + crate::enginectl::gpu_settle( + before, + || async { + let g = self.gpus().await; + (crate::enginectl::gpu_used_mb(&g), !g.is_empty()) + }, + self.gpu_pause, + ) + .await + } + fn st(&self) -> MutexGuard<'_, State> { self.state.lock().unwrap_or_else(|e| e.into_inner()) } @@ -789,7 +819,7 @@ impl Tune { }; if held.snapshot().main_was_active { tr.set_phase("arrêt du moteur principal"); - let gpus = crate::sysinfo::gpu_stats().await; + let gpus = self.gpus().await; let before = crate::enginectl::gpu_used_mb(&gpus); if let Err(e) = Self::svc_os(ctx, "stop").await { return Err(format!( @@ -799,7 +829,7 @@ impl Tune { } crate::enginectl::dictate_stop(ctx).await; if !gpus.is_empty() { - gpu_settled(before).await; + self.gpu_settled(before).await; } crate::sysinfo::GPU_MONITOR.invalidate().await; } @@ -824,7 +854,7 @@ impl Tune { .await .map_err(|e| format!("la configuration active ne se lance pas : {e}"))?; let probe = base_launch.probe.clone().unwrap_or_default(); - let smi = crate::sysinfo::gpu_stats().await; + let smi = self.gpus().await; let mut env = tc::Env { help: probe.help.clone(), gpus: probe.gpus, @@ -925,7 +955,8 @@ impl Tune { if !env.smi { return (-1, false); } - let g: Vec<(i64, i64)> = crate::sysinfo::gpu_stats() + let g: Vec<(i64, i64)> = self + .gpus() .await .iter() .map(|g| (g.total, g.used)) @@ -1621,7 +1652,7 @@ struct Loaded { struct Stopper<'a> { run: Option<(i64, Arc)>, held: &'a Held<'a>, - settle: Duration, + tune: &'a Tune, } impl Stopper<'_> { @@ -1629,7 +1660,7 @@ impl Stopper<'_> { let Some((pid, done)) = self.run.take() else { return; }; - let before = crate::enginectl::gpu_used_mb(&crate::sysinfo::gpu_stats().await); + let before = crate::enginectl::gpu_used_mb(&self.tune.gpus().await); let d = done.clone(); let _ = tokio::task::spawn_blocking(move || { lock::kill_tree(pid, &|| d.load(Ordering::SeqCst)); @@ -1643,9 +1674,9 @@ impl Stopper<'_> { eprintln!("[loki tune] l'essai (PID {pid}) ne s'est pas arrêté dans les temps"); } if before > 0 { - gpu_settled(before).await; + self.tune.gpu_settled(before).await; } else { - tokio::time::sleep(self.settle).await; + tokio::time::sleep(self.tune.settle).await; } } } @@ -1744,7 +1775,7 @@ impl<'a> Trialer<'a> { let mut stopper = Stopper { run: None, held: self.held, - settle: self.tune.settle, + tune: self.tune, }; let log_path = dir.join("engine.log"); let (out, err) = @@ -1998,19 +2029,6 @@ fn phys_cores(probe: &tc::Probe) -> i64 { crate::serve::threads::physical_cores(root, &online, &atom).unwrap_or(0) } -/// `gpuUsedSettled`. -async fn gpu_settled(before: i64) -> i64 { - crate::enginectl::gpu_settle( - before, - || async { - let g = crate::sysinfo::gpu_stats().await; - (crate::enginectl::gpu_used_mb(&g), !g.is_empty()) - }, - Duration::from_millis(500), - ) - .await -} - /// La dernière ligne de la sortie, sans couleurs ; vide : `fallback`. fn last_line(path: &Path, fallback: &str) -> String { let out = std::fs::read(path) @@ -2173,7 +2191,7 @@ pub async fn serve( ..Default::default() }; if d.int("budget_min") > 0 { - opts.budget = Duration::from_secs(d.int("budget_min") as u64 * 60); + opts.budget = tune.budget_unit * d.int("budget_min").min(1 << 20) as u32; } let (code, out) = tune.start(ctx, opts).await; send_json(code, &out) @@ -2206,3 +2224,59 @@ pub async fn serve( ), } } + +#[cfg(test)] +mod tests { + use super::*; + + /// L'ETA : inconnue (-1) tant qu'aucun essai n'est mesuré ; ensuite la + /// moyenne fois les essais restants, bornée par le budget qui reste, + /// jamais négative. + #[test] + fn eta_du_suivi() { + let tr = Tracker::new(); + tr.lock().budget = Duration::from_secs(600); + assert_eq!(tr.snapshot()["eta_sec"], json!(-1)); + assert_eq!(tr.snapshot()["trials"], Value::Null); + { + let mut st = tr.lock(); + st.avg_sec = 10.0; + st.left = 5; + } + assert_eq!(tr.snapshot()["eta_sec"], json!(50)); + tr.lock().left = 100; + let eta = tr.snapshot()["eta_sec"].as_f64().unwrap(); + assert!(eta > 590.0 && eta <= 600.0, "{eta}"); + tr.lock().started = Instant::now() - Duration::from_secs(700); + assert_eq!(tr.snapshot()["eta_sec"], json!(0)); + // Fin : le temps écoulé s'arrête. + let started = tr.lock().started; + tr.lock().ended = Some(started + Duration::from_secs(3)); + assert_eq!(tr.snapshot()["elapsed_sec"], json!(3)); + // Un essai remplacé à sa place, un nouveau ajouté. + let i = tr.put(None, Trial::default()); + tr.put( + Some(i), + Trial { + label: "a".into(), + ..Default::default() + }, + ); + tr.put(Some(9), Trial::default()); + let t = tr.snapshot()["trials"].clone(); + assert_eq!(t.as_array().unwrap().len(), 2); + assert_eq!(t[0]["label"], json!("a")); + } + + #[test] + fn arrondi_a_la_minute() { + let m = |s: u64| round_minute(Duration::from_secs(s)).as_secs(); + assert_eq!( + (m(29), m(30), m(89), m(90), m(3600)), + (0, 60, 60, 120, 3600) + ); + assert_eq!(build_number("b7000-x"), 7000); + assert_eq!(build_number("7000"), 0); + assert_eq!(build_number("b"), 0); + } +} diff --git a/rust/loki-rs/src/tune/lock.rs b/rust/loki-rs/src/tune/lock.rs index 6f50b87..50c57e8 100644 --- a/rust/loki-rs/src/tune/lock.rs +++ b/rust/loki-rs/src/tune/lock.rs @@ -213,7 +213,12 @@ pub fn pid_alive(_pid: i64) -> bool { /// `tuneProcAlive` : `p` désigne-t-il encore le MÊME processus ? pub fn proc_alive(p: &Proc) -> bool { - let Some((start, exe)) = proc_identity(p.pid) else { + same_proc(p, proc_identity(p.pid)) +} + +/// [`proc_alive`], l'identité lue (`None` : processus absent). +pub fn same_proc(p: &Proc, identity: Option<(String, String)>) -> bool { + let Some((start, exe)) = identity else { return false; }; if !p.start.is_empty() && start != p.start { @@ -251,6 +256,12 @@ pub fn self_proc() -> Proc { /// sondé pour un orphelin d'un ancien Loki). #[cfg(unix)] pub fn kill_tree(pid: i64, gone: &dyn Fn() -> bool) { + kill_tree_within(pid, gone, Duration::from_secs(10)); +} + +/// [`kill_tree`], `grace` laissé au SIGTERM. +#[cfg(unix)] +pub fn kill_tree_within(pid: i64, gone: &dyn Fn() -> bool, grace: Duration) { let Ok(p) = i32::try_from(pid) else { return; }; @@ -273,7 +284,7 @@ pub fn kill_tree(pid: i64, gone: &dyn Fn() -> bool) { libc::kill(p, libc::SIGTERM); } } - if wait(Duration::from_secs(10)) { + if wait(grace) { return; } // SAFETY: idem. diff --git a/rust/loki-rs/src/tune/tests.rs b/rust/loki-rs/src/tune/tests.rs index e63c4b1..326c568 100644 --- a/rust/loki-rs/src/tune/tests.rs +++ b/rust/loki-rs/src/tune/tests.rs @@ -72,11 +72,13 @@ async fn verrou() { drop(l); alive.store(true, Ordering::SeqCst); let l2 = lf.acquire("web", false, no_reap).await.unwrap(); - // Un verrou rendu par sa poignée n'emporte pas celui d'un autre. - let other = l2.snapshot(); - l2.release(); + // Un verrou d'un autre processus (même PID et même prise, autre + // démarrage) n'est jamais retiré par release. + let mut other = l2.snapshot(); + other.owner.start = "autre".into(); std::fs::write(&lf.path, serde_json::to_vec(&other).unwrap()).unwrap(); - assert!(lf.path.exists()); + l2.release(); + assert_eq!(read_owner(&lf.path), Some(other), "verrou d'autrui retiré"); } /// Un verrou périmé dans la place, et `acquire` qui le fait écarter. @@ -213,6 +215,45 @@ async fn menage_jamais_sur_un_verrou_vivant() { ); } +/// Un verrou sans PID n'en est pas un. +#[test] +fn verrou_sans_pid() { + let tmp = tempfile::tempdir().unwrap(); + let p = tmp.path().join(LOCK_NAME); + std::fs::write(&p, r#"{"owner":{"pid":0},"via":"cli"}"#).unwrap(); + assert_eq!(read_owner(&p), None); + std::fs::write(&p, r#"{"owner":{"pid":-3},"via":"cli"}"#).unwrap(); + assert_eq!(read_owner(&p), None); + std::fs::write(&p, r#"{"owner":{"pid":7},"via":"cli"}"#).unwrap(); + assert_eq!(read_owner(&p).map(|o| o.owner.pid), Some(7)); + std::fs::write(&p, r#"{"owner":{"pid":"7"}}"#).unwrap(); + assert_eq!(read_owner(&p), None, "illisible"); +} + +/// `tuneProcAlive` : l'instant de démarrage doit concorder s'il est noté ; +/// le binaire, s'il est lisible et noté, doit être l'un des admis. +#[test] +fn meme_processus() { + let id = |s: &str, e: &str| Some((s.to_string(), e.to_string())); + let p = Proc { + pid: 9, + start: "b/1".into(), + exes: vec!["/usr/bin/loki".into(), "/opt/llama-server".into()], + }; + assert!(same_proc(&p, id("b/1", "/opt/llama-server"))); + assert!(same_proc(&p, id("b/1", "/usr/bin/loki"))); + assert!(!same_proc(&p, id("b/2", "/usr/bin/loki")), "PID recyclé"); + assert!(!same_proc(&p, id("b/1", "/usr/bin/autre"))); + // Binaire illisible (droits) : l'instant suffit. + assert!(same_proc(&p, id("b/1", ""))); + assert!(!same_proc(&p, None)); + let bare = Proc { + pid: 9, + ..Default::default() + }; + assert!(same_proc(&bare, id("x", "/quoi/que/ce/soit"))); +} + /// `TestTuneSameExeDeleted`. #[test] fn binaire_remplace() { @@ -222,6 +263,9 @@ fn binaire_remplace() { )); assert!(!same_exe("/usr/local/bin/loki", "/usr/bin/llama-server")); assert!(!same_exe("", "")); + // Un nom vide n'est jamais le même binaire, même face à « . ». + assert!(!same_exe(".", "")); + assert!(!same_exe("", ".")); } /// L'identité d'un processus comme le Go l'écrit : instant de démarrage @@ -291,4 +335,58 @@ fn arret_du_groupe() { waiter.join().unwrap(); assert!(t0.elapsed() < Duration::from_secs(5), "SIGTERM ignoré ?"); assert!(!pid_alive(pid)); + + // Un essai qui ignore SIGTERM : SIGKILL au groupe après le délai. + let mut child = std::process::Command::new("sh") + .args(["-c", "trap '' TERM; while :; do sleep 1; done"]) + .process_group(0) + .spawn() + .unwrap(); + let pid = i64::from(child.id()); + std::thread::sleep(Duration::from_millis(200)); + let reaped = Arc::new(AtomicBool::new(false)); + let r = reaped.clone(); + let waiter = std::thread::spawn(move || { + let _ = child.wait(); + r.store(true, Ordering::SeqCst); + }); + kill_tree_within( + pid, + &|| reaped.load(Ordering::SeqCst), + Duration::from_millis(300), + ); + waiter.join().unwrap(); + assert!(reaped.load(Ordering::SeqCst), "essai toujours là"); +} + +/// `plan_apply` : un résultat qui touche une clé sur option sans l'option +/// (enregistré par une autre version, ou altéré) est refusé avant toute +/// écriture ; avec l'option, le preset est réécrit ; une valeur vide retire +/// la clé. +#[test] +fn application_planifiee() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("presets"); + std::fs::create_dir_all(&dir).unwrap(); + let content = "# NAME=Dense\nCTX=32768\nUBATCH=2048\nBATCH=1024\n"; + std::fs::write(dir.join("dense.env"), content).unwrap(); + let tune = super::job::Tune::new(tmp.path().to_path_buf(), "loki".into()); + let mut res = super::job::TuneResult { + preset_id: "dense".into(), + preset_name: "Dense".into(), + preset_fp: crate::presets::preset_fingerprint(content), + ..Default::default() + }; + res.set.insert("SPEC".into(), "ngram".into()); + assert_eq!(tune.plan_apply(&res), Err("clé SPEC intouchable".into())); + res.opt_in = true; + let (before, patched) = tune.plan_apply(&res).unwrap(); + assert_eq!(before, content); + assert!(patched.contains("SPEC=ngram"), "{patched}"); + res.set.insert("BATCH".into(), String::new()); + let (_, patched) = tune.plan_apply(&res).unwrap(); + assert!( + !crate::presets::parse_env(&patched).contains_key("BATCH"), + "{patched}" + ); } diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index 26dac21..47704a0 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -81,6 +81,9 @@ pub struct FakeGo { /// /internal/v1/preset/apply écrit-il le preset dans la configuration /// (`applyPresetFile` : clés « appareil » et BIN de la machine gardés) ? pub apply_presets: Mutex, + /// Clés que l'application d'un preset ajoute à la configuration (un + /// réglage changé ailleurs pendant l'application). + pub apply_extra: Mutex>, /// /internal/v1/chat/generating : le Go écrit une réponse. pub generating: Mutex, /// /internal/v1/service/prestart reçus. @@ -571,6 +574,9 @@ async fn handle(st: Arc, req: Request) -> Response if let Some(bin) = cur.get("BIN").filter(|v| !v.is_empty()) { next.insert("BIN".into(), String::from_utf8_lossy(bin).into_owned()); } + for (k, v) in st.apply_extra.lock().unwrap().iter() { + next.insert(k.clone(), v.clone()); + } *cur = next.into_iter().map(|(k, v)| (k, v.into_bytes())).collect(); } st.preset_applies.lock().unwrap().push((path, mode)); diff --git a/rust/loki-rs/tests/tune.rs b/rust/loki-rs/tests/tune.rs index 75114e9..c90b959 100644 --- a/rust/loki-rs/tests/tune.rs +++ b/rust/loki-rs/tests/tune.rs @@ -20,17 +20,25 @@ use loki_rs::tune::job::Tune; use serde_json::{Value, json}; const FAUX: &str = "LOKI_TUNE_FAUX"; +const VRAM_NOTE: &str = "VRAM libre non mesurable (nvidia-smi absent) : ni micro-lot plus grand ni marges --fit essayés"; const BUSY: &str = "optimisation en cours (loki tune) : le moteur est réservé aux essais — attends la fin ou annule-la"; // --------------------------------------------------------------------------- // Le faux moteur // --------------------------------------------------------------------------- -/// Ce que le faux moteur annonce : son contexte, et s'il travaille déjà. +/// Ce que le faux moteur annonce : son contexte, s'il travaille déjà, et ses +/// débits (0 : prefill selon le micro-lot, decode à 50 jetons/s) ; `down` : +/// /health en 503 ; `flat_props` : /props sans `default_generation_settings` +/// (le contexte au premier niveau seulement). #[derive(Default)] struct Knobs { n_ctx: Mutex, busy: Mutex, + pps: Mutex, + tg: Mutex, + down: Mutex, + flat_props: Mutex, } /// Un faux llama-server (fil à part, jusqu'à la fin du processus). @@ -73,8 +81,27 @@ fn answer( let mut body = vec![0u8; len]; r.read_exact(&mut body)?; let req: Value = serde_json::from_slice(&body).unwrap_or(Value::Null); + // Un seul slot : comme llama-server, un id_slot qu'il n'a pas est refusé. + if path == "/v1/chat/completions" && req["id_slot"].as_i64().is_some_and(|i| i >= 1) { + let b = r#"{"error":{"code":400,"message":"slot id invalide"}}"#; + let mut w = st; + return write!( + w, + "HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{b}", + b.len() + ); + } + if path == "/health" && *knobs.down.lock().unwrap() { + let mut w = st; + return w.write_all( + b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + } let out = match path.as_str() { "/health" => json!({ "status": "ok" }), + "/props" if *knobs.flat_props.lock().unwrap() => json!({ + "n_ctx": n_ctx, "total_slots": 1, "build_info": "b7000-faux", + }), "/props" => json!({ "n_ctx": n_ctx, "total_slots": 1, "build_info": "b7000-faux", "default_generation_settings": { "n_ctx": n_ctx }, @@ -103,14 +130,21 @@ fn answer( *l = total; let new = total - cached; // Le prefill ralentit avec le micro-lot ; le decode, non. - let pps = 4_000_000.0 / ubatch as f64; + let pps = match *knobs.pps.lock().unwrap() { + 0.0 => 4_000_000.0 / ubatch as f64, + v => v, + }; + let tg = match *knobs.tg.lock().unwrap() { + 0.0 => 50.0, + v => v, + }; let n_gen = req["max_tokens"].as_i64().unwrap_or(16); json!({ "choices": [{ "message": { "content": "ok" } }], "usage": { "prompt_tokens": total }, "timings": { "prompt_n": new, "prompt_ms": (new as f64 / pps * 1000.0).max(1.0), - "predicted_n": n_gen, "predicted_ms": n_gen as f64 / 50.0 * 1000.0, + "predicted_n": n_gen, "predicted_ms": n_gen as f64 / tg * 1000.0, "cache_n": cached, }, }) @@ -153,14 +187,20 @@ fn serve() { } let mut args = vec!["-m".to_string(), get("MODEL"), "-c".into(), get("CTX")]; for (k, f) in [("UBATCH", "-ub"), ("BATCH", "-b")] { - if !get(k).is_empty() { + if !get(k).is_empty() && !(k == "BATCH" && !get("SANS_BATCH").is_empty()) { args.extend([f.to_string(), get(k)]); } } + // Une ligne qui change ce que le modèle calcule. + if get("DENATURE") == ub { + args.push("--no-mmap".into()); + } args.extend(["--host".into(), get("HOST"), "--port".into(), get("PORT")]); let launch = json!({ "bin": "/opt/faux/llama-server", "args": args, - "probe": { "help": "", "gpus": 0, "vram_mib": 0, "model_bytes": 1000, "moe": false, "cpu_threads": 8 }, + "probe": { "help": "", "gpus": 0, "vram_mib": 0, + "model_bytes": get("MODEL_BYTES").parse::().unwrap_or(1000), "moe": false, + "cpu_threads": get("CPU_THREADS").parse::().unwrap_or(8) }, }); std::fs::write(dir.join("launch.json"), launch.to_string()).unwrap(); return; @@ -168,11 +208,30 @@ fn serve() { if get("ECHEC") == ub { die("llama_model_load: error loading model: out of memory"); } + // Un essai qui ne répond jamais. + if get("MUET") == ub { + std::thread::sleep(Duration::from_secs(600)); + return; + } let port: u16 = get("PORT").parse().unwrap(); let l = TcpListener::bind(("127.0.0.1", port)).unwrap(); let _ = std::io::stdout().write_all(b"load_tensors: offloaded 33/33 layers to GPU\n"); let knobs = Knobs::default(); - *knobs.n_ctx.lock().unwrap() = get("CTX").parse().unwrap(); + // Un essai qui ne garde pas le contexte de la référence. + let ctx: i64 = get("CTX").parse().unwrap(); + *knobs.n_ctx.lock().unwrap() = if get("CTX_FAUX") == ub { ctx / 2 } else { ctx }; + *knobs.flat_props.lock().unwrap() = get("PROPS_PLAT") == "1"; + // Débits imposés par micro-lot : « 1024:2000,512:1953 ». + let speed = |k: &str| { + get(k) + .split(',') + .filter_map(|p| p.split_once(':')) + .find(|(u, _)| *u == ub) + .and_then(|(_, v)| v.parse::().ok()) + .unwrap_or(0.0) + }; + *knobs.pps.lock().unwrap() = speed("VITESSE"); + *knobs.tg.lock().unwrap() = speed("DECODE"); fake_llama(l, ub.parse().unwrap_or(2048), Arc::new(knobs)); // Arrêté par la façade ; un essai oublié (test interrompu) s'en va seul. std::thread::sleep(Duration::from_secs(600)); @@ -195,6 +254,15 @@ struct Tn { const PRESET: &str = "# NAME=Dense\nCTX=32768\nUBATCH=2048\nREASONING=off\nCACHE_ISOLATE=off\n"; async fn start() -> Tn { + start_with(|_, _| {}, |_, _| {}).await +} + +/// [`start`], l'optimiseur réglé par `ft` et la façade par `fo` (le +/// répertoire en second). +async fn start_with( + ft: impl FnOnce(&mut Tune, &std::path::Path), + fo: impl FnOnce(&mut fakego::Opts, &std::path::Path), +) -> Tn { let tmp = tempfile::tempdir().unwrap(); let home = tmp.path().to_path_buf(); let mut t = Tune::new(home.clone(), std::env::current_exe().unwrap()); @@ -204,19 +272,19 @@ async fn start() -> Tn { t.settle = Duration::ZERO; t.load_timeout = Duration::from_secs(20); t.lease_ttl = Duration::from_millis(300); + t.gpu_pause = Duration::ZERO; + ft(&mut t, &home); let tune = Arc::new(t); // Moteur et modèle présents : la relance du vrai moteur passe ses // vérifications. std::fs::write(home.join("llama-server"), "x").unwrap(); std::fs::write(home.join("m.gguf"), "x").unwrap(); - let (facade, go, stop, _) = fakego::start_with( - home.clone(), - fakego::Opts { - tune: Some(tune), - ..Default::default() - }, - ) - .await; + let mut opts = fakego::Opts { + tune: Some(tune), + ..Default::default() + }; + fo(&mut opts, &home); + let (facade, go, stop, _) = fakego::start_with(home.clone(), opts).await; // Le vrai moteur, en service. let l = TcpListener::bind("127.0.0.1:0").unwrap(); let port = l.local_addr().unwrap().port(); @@ -276,6 +344,8 @@ impl Tn { async fn wait(&self, what: &str, key: &str) -> Value { for _ in 0..6000 { let st = self.status().await; + // Notre propre verrou n'est jamais « une optimisation ailleurs ». + assert_eq!(st.get("elsewhere"), None, "{st}"); if st[key] != json!(true) { return st; } @@ -284,6 +354,15 @@ impl Tn { panic!("{what} : ne finit pas"); } + /// Application dans le preset ; son issue (`apply` de l'état). + async fn apply_preset(&self) -> Value { + let (code, out) = self + .call("POST", "/api/tune/apply", r#"{"target":"preset"}"#) + .await; + assert_eq!((code, out), (202, json!({ "ok": true }))); + self.wait("application", "applying").await["apply"].clone() + } + fn config(&self) -> HashMap { ["MODEL", "CTX", "UBATCH", "BATCH", "PORT"] .iter() @@ -337,11 +416,49 @@ async fn optimisation_complete() { tokio::time::sleep(Duration::from_millis(5)).await; } assert!(seen_lock, "verrou jamais posé"); + // Chaque essai lancé est noté dans le verrou : un Loki tué pendant la + // mesure l'arrête à son redémarrage. + let mut trial = None; + for _ in 0..4000 { + if let Some(t) = + loki_rs::tune::lock::read_owner(&tn.home.join("tune.lock")).and_then(|o| o.trial) + { + trial = Some(t); + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + let trial = trial.expect("essai jamais noté dans le verrou"); + assert!(trial.pid > 0 && trial.pid != i64::from(std::process::id())); + assert_eq!( + trial.exes[0], + std::env::current_exe() + .unwrap() + .to_string_lossy() + .into_owned() + ); let (code, out) = tn.call("POST", "/api/tune", "{}").await; assert_eq!(code, 409); assert_eq!(out["error"], json!(BUSY)); + // Rien ne s'applique pendant la mesure. + assert_eq!( + tn.call("POST", "/api/tune/apply", r#"{"target":"preset"}"#) + .await, + (409, json!({ "ok": false, "error": BUSY })) + ); let st = tn.wait("optimisation", "running").await; assert_eq!(st["error"], Value::Null, "{st}"); + // La progression : la référence puis chaque essai, une fois chacun. + let shown: Vec<(&str, &str)> = st["trials"] + .as_array() + .unwrap() + .iter() + .map(|t| (t["label"].as_str().unwrap(), t["status"].as_str().unwrap())) + .collect(); + assert_eq!(shown.len(), 5, "{shown:?}"); + assert_eq!(shown[0], ("configuration actuelle", "mesuré")); + assert!(shown.iter().all(|t| t.1 != "en cours"), "{shown:?}"); + assert_eq!(st["eta_sec"], json!(0)); let res = &st["result"]; assert_eq!(res["preset_id"], json!("dense")); assert_eq!(res["preset_name"], json!("Dense")); @@ -366,6 +483,9 @@ async fn optimisation_complete() { ] ); assert_eq!(tr[0].1, "retenu", "{tr:?}"); + // Confirmé seulement s'il promet : le premier, pas les suivants. + let runs = |i: usize| res["trials"][i]["runs"].as_array().unwrap().len(); + assert_eq!((runs(0), runs(1), runs(2), runs(3)), (2, 1, 1, 1)); assert!( tr[0].2.starts_with('−') && tr[0].2.contains("par tour (seuil"), "{tr:?}" @@ -374,8 +494,19 @@ async fn optimisation_complete() { res["best"], json!(tr.iter().rfind(|t| t.1 == "retenu").unwrap().0) ); - assert_eq!(res["set"]["UBATCH"], json!("512")); - assert!(res["gain"].as_f64().unwrap() > 0.03, "{res}"); + assert_eq!(res["set"], json!({ "BATCH": "2048", "UBATCH": "512" })); + assert_eq!(res["old"], json!({ "BATCH": "", "UBATCH": "2048" })); + let (base_turn, best_turn) = ( + res["baseline"]["turn_sec"].as_f64().unwrap(), + res["trials"][0]["turn_sec"].as_f64().unwrap(), + ); + let gain = res["gain"].as_f64().unwrap(); + assert!( + (gain - (base_turn - best_turn) / base_turn).abs() < 1e-12, + "{gain} {base_turn} {best_turn}" + ); + // Aussi rapide que le meilleur : « ≈ 0 % ». + assert_eq!(tr[1].2, "≈ 0 % (seuil 3.0 %)"); let skipped: Vec<&str> = res["skipped"] .as_array() .unwrap() @@ -383,12 +514,7 @@ async fn optimisation_complete() { .map(|s| s.as_str().unwrap()) .collect(); assert!(skipped.contains(&"marges : non demandé"), "{skipped:?}"); - assert!( - res["notes"] - .as_array() - .unwrap() - .contains(&json!("VRAM libre non mesurable (nvidia-smi absent) : ni micro-lot plus grand ni marges --fit essayés")) - ); + assert_eq!(res["notes"], json!([VRAM_NOTE])); // Le vrai moteur : arrêté, puis relancé, sans le refus de l'optimiseur ; // la dictée coupée ; le verrou rendu ; le bail aussi. assert_eq!(*tn.go.service_os.lock().unwrap(), ["stop", "start"]); @@ -410,6 +536,14 @@ async fn optimisation_complete() { assert_eq!(last["ok"], json!(true)); assert_eq!(last["preset_changed"], json!(false)); assert_eq!(last["result"], *res); + // Le build du moteur actuel est inconnu (binaire illisible) : rien à + // signaler. + assert_eq!(last.get("engine_changed"), None, "{last}"); + assert_eq!( + tn.call("POST", "/api/tune/apply", r#"{"target":"ailleurs"}"#) + .await, + (400, json!({ "ok": false, "error": "cible inconnue" })) + ); // Copie : rien d'autre ne bouge. let (code, out) = tn @@ -743,7 +877,19 @@ async fn orphelin_et_application_interrompue() { .spawn() .unwrap(); let pid = i64::from(child.id()); - let (start, exe) = loki_rs::tune::lock::proc_identity(pid).unwrap(); + // L'identité une fois l'exec visible dans /proc : lue trop tôt, elle + // porterait l'exécutable du test, et l'essai ne serait plus reconnu. + let mut ident = None; + for _ in 0..200 { + if let Some((start, exe)) = loki_rs::tune::lock::proc_identity(pid) + && exe.ends_with("sleep") + { + ident = Some((start, exe)); + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + let (start, exe) = ident.expect("exec de sleep jamais visible"); let dead = json!({ "pid": 999999, "start": "x" }); let write = |trial: Value, extra: Value| { let mut o = json!({ "owner": dead, "via": "cli", "phase": "essais", "since": 1, "trial": trial, "main_was_active": false }); @@ -800,6 +946,16 @@ async fn orphelin_et_application_interrompue() { old ); assert_eq!(tn.config()["UBATCH"], "2048"); + // Hors phase d'application, rien n'est défait. + tn.preset(&PRESET.replace("UBATCH=2048", "UBATCH=999")); + write( + Value::Null, + json!({ "phase": "essais", "backup": backup, "preset_id": "dense", "preset_name": "Dense" }), + ); + tn.call("POST", "/api/tune", r#"{"stages":["aucune"]}"#) + .await; + tn.wait("essais interrompus", "running").await; + assert_eq!(tn.config()["UBATCH"], "999"); } /// Sans la façade pour l'optimiseur, ses routes partent au Go. @@ -819,3 +975,488 @@ async fn sans_la_facade() { assert_eq!(String::from_utf8_lossy(&b), format!("go:{p}")); } } + +/// Pendant la prise du bail, un second lancement est refusé. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn second_lancement_pendant_le_bail() { + let tn = Arc::new(start().await); + *tn.go.lease_delay.lock().unwrap() = Some(Duration::from_millis(400)); + let first = { + let tn = tn.clone(); + tokio::spawn(async move { + tn.call("POST", "/api/tune", r#"{"stages":["aucune"]}"#) + .await + }) + }; + tokio::time::sleep(Duration::from_millis(150)).await; + // Avant la prise du bail : aucune estimation encore. + assert_eq!(tn.status().await["running"], json!(false)); + assert_eq!( + tn.call("POST", "/api/tune", "{}").await, + (409, json!({ "ok": false, "error": BUSY })) + ); + assert_eq!(first.await.unwrap().0, 202); + let st = tn.status().await; + if st["running"] == json!(true) { + assert_eq!( + st["eta_sec"], + json!(-1), + "aucun essai mesuré : ETA inconnue" + ); + } + tn.wait("optimisation", "running").await; + assert_eq!(*tn.go.lease_whys.lock().unwrap(), vec!["tune".to_string()]); +} + +/// Une application qui échoue avant d'écrire quoi que ce soit (une autre +/// optimisation tient le moteur) : l'erreur, sans journal. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn application_refusee_sans_journal() { + let tn = start().await; + tn.call("POST", "/api/tune", r#"{"stages":["lots"]}"#).await; + let st = tn.wait("optimisation", "running").await; + assert_eq!(st["result"]["set"]["UBATCH"], json!("512"), "{st}"); + let other = json!({ "owner": { "pid": 1 }, "via": "cli", "phase": "essais", "since": 1700000000, "main_was_active": false }); + std::fs::write(tn.home.join("tune.lock"), other.to_string()).unwrap(); + let (code, _) = tn + .call("POST", "/api/tune/apply", r#"{"target":"preset"}"#) + .await; + assert_eq!(code, 202); + let mut st = tn.status().await; + for _ in 0..500 { + if st["applying"] == json!(false) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + st = tn.status().await; + } + let apply = &st["apply"]; + assert_eq!(apply["log"], Value::Null, "{st}"); + assert_eq!(apply["message"], json!("")); + assert!( + apply["error"] + .as_str() + .unwrap() + .starts_with("une optimisation tourne déjà (cli, PID 1, depuis "), + "{st}" + ); + assert_eq!(st["elsewhere"], json!("cli")); + assert!( + std::fs::read_to_string(tn.home.join("presets/dense.env")) + .unwrap() + .contains("UBATCH=2048"), + "rien n'est écrit" + ); + std::fs::remove_file(tn.home.join("tune.lock")).unwrap(); +} + +/// La façade pilote elle-même le moteur (LOKI_ENGINE=rust) : arrêt et +/// relance par son superviseur, pas par le Go. +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn moteur_pilote_par_la_facade() { + use std::os::unix::fs::PermissionsExt; + let tn = start_with( + |_, _| {}, + |o, home| { + let script = home.join("superviseur.sh"); + std::fs::write( + &script, + "#!/bin/sh\necho \"$1 $2\" >> \"$SUPERVISEUR_LOG\"\n", + ) + .unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + let ctl = loki_rs::enginectl::EngineCtl::new( + script, + vec![( + "SUPERVISEUR_LOG".into(), + home.join("superviseur.log").to_string_lossy().into_owned(), + )], + ); + o.engine_ctl = Some(Arc::new(ctl)); + // Le moteur tourne : son PID, ce processus. + let pid = loki_rs::engine::Paths { + home: home.to_path_buf(), + service: "loki-engine".into(), + } + .pid(); + std::fs::create_dir_all(pid.parent().unwrap()).unwrap(); + std::fs::write(pid, format!("{}\n", std::process::id())).unwrap(); + }, + ) + .await; + *tn.go.service_active.lock().unwrap() = false; + tn.call("POST", "/api/tune", r#"{"stages":["aucune"]}"#) + .await; + let st = tn.wait("optimisation", "running").await; + assert_eq!(st["error"], Value::Null, "{st}"); + assert_eq!( + std::fs::read_to_string(tn.home.join("superviseur.log")).unwrap(), + "engine stop\nengine start\n" + ); + assert!( + tn.go.service_os.lock().unwrap().is_empty(), + "rien par le Go" + ); +} + +/// Les essais écartés après composition : ligne dénaturée, ligne déjà +/// mesurée (doublon), contexte perdu au chargement. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn essais_ecartes() { + let tn = start().await; + tn.preset(&format!( + "{PRESET}DENATURE=1024\nCTX_FAUX=512\nSANS_BATCH=1\n" + )); + tn.call("POST", "/api/tune", r#"{"stages":["lots"]}"#).await; + let st = tn.wait("optimisation", "running").await; + let tr = trials(&st["result"]); + assert_eq!(tr.len(), 4, "{tr:?}"); + assert_eq!( + tr[0], + ( + "UBATCH=512 BATCH=2048".into(), + "écarté".into(), + "dénature : contexte 16384 × 1 slot(s) au lieu de 32768 × 1".into() + ) + ); + assert_eq!( + tr[1], + ( + "UBATCH=512 BATCH=1024".into(), + "doublon".into(), + "même ligne de commande que « UBATCH=512 BATCH=2048 »".into() + ) + ); + assert_eq!(tr[2].1, "écarté"); + assert!( + tr[2].2.starts_with("dénature : ") && tr[2].2.contains("--no-mmap"), + "{tr:?}" + ); + assert_eq!( + tr[3], + ( + "UBATCH=2048 BATCH=4096".into(), + "doublon".into(), + "même ligne de commande que « configuration actuelle »".into() + ) + ); + assert_eq!(st["result"]["best"], Value::Null); +} + +/// La VRAM mesurée (faux nvidia-smi) : marge libre après chaque mesure, et +/// les essais trop justes écartés. +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn vram_mesuree() { + use std::os::unix::fs::PermissionsExt; + let tn = start_with( + |t, home| { + let smi = home.join("nvidia-smi"); + std::fs::write( + &smi, + format!("#!/bin/sh\ncat '{}'\n", home.join("smi.txt").display()), + ) + .unwrap(); + std::fs::set_permissions(&smi, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::write(home.join("smi.txt"), "Fausse, 1000, 24000, 0, 40\n").unwrap(); + t.nvidia_smi = smi.to_string_lossy().into_owned(); + }, + |_, _| {}, + ) + .await; + tn.call("POST", "/api/tune", r#"{"stages":["lots"]}"#).await; + let st = tn.wait("optimisation", "running").await; + let res = &st["result"]; + assert_eq!(res["baseline"]["headroom_mib"], json!(23000), "{res}"); + assert_eq!(res["trials"][0]["headroom_mib"], json!(23000)); + assert_eq!(res["trials"][0]["status"], json!("retenu")); + assert_eq!(res["notes"], Value::Null, "VRAM mesurable : aucune note"); + // Une carte presque pleine : tout essai est trop juste. + std::fs::write(tn.home.join("smi.txt"), "Fausse, 23500, 24000, 0, 40\n").unwrap(); + tn.preset(&format!("{PRESET}MMPROJ=/models/vision.gguf\n")); + tn.call("POST", "/api/tune", r#"{"stages":["lots"]}"#).await; + let st = tn.wait("trop juste", "running").await; + let res = &st["result"]; + assert_eq!( + res["notes"], + json!(["la configuration actuelle laisse déjà moins de 1280 Mio libres sur une carte"]) + ); + let tr = trials(res); + assert!( + tr.iter() + .all(|t| t.1 == "écarté" && t.2 == "trop juste : 500 Mio libres sur une carte (< 1280)"), + "{tr:?}" + ); + assert_eq!(res["best"], Value::Null); +} + +/// Le budget atteint : les essais restants ne sont pas faits. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn budget_atteint() { + let tn = start_with(|t, _| t.budget_unit = Duration::from_millis(100), |_, _| {}).await; + tn.call("POST", "/api/tune", r#"{"stages":["lots"],"budget_min":1}"#) + .await; + let st = tn.wait("budget", "running").await; + assert_eq!( + st["result"]["partial"], + json!("budget de 0s atteint : 4 essai(s) non faits") + ); + assert_eq!(st["result"]["trials"], Value::Null); +} + +/// La profondeur : celle que la référence tient avec le plus gros +/// préchauffage essayé. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn profondeur_commune() { + let tn = start().await; + // Un moteur qui ne donne le contexte qu'au premier niveau de /props. + tn.preset(&format!( + "{}PROPS_PLAT=1\n", + PRESET.replace("CTX=32768", "CTX=20000") + )); + tn.call("POST", "/api/tune", r#"{"stages":["aucune"]}"#) + .await; + let st = tn.wait("optimisation", "running").await; + assert_eq!(st["result"]["depth"], json!(7288), "{st}"); + assert_eq!(st["result"]["n_ctx"], json!(20000)); +} + +/// Les notes : modèle plus gros que la RAM ; relance du vrai moteur qui +/// échoue après une optimisation réussie ; poids tirés de la télémétrie ; +/// essais d'une optimisation précédente effacés. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn notes_et_poids() { + let tn = start().await; + let (_, total_mb) = loki_rs::sysinfo::ram_usage_mb(); + assert!(total_mb > 0); + // La moitié de la RAM : pas de note (des octets, comparés à des Mio). + tn.preset(&format!("{PRESET}MODEL_BYTES={}\n", total_mb << 19)); + let stale = tn.home.join("tune/run/e09"); + std::fs::create_dir_all(&stale).unwrap(); + let recs: Vec = (0..20) + .map(|_| json!({ "kind": "main", "complete": true, "total": 4000, "cached": 0, "new": 1000, "gen": 100 })) + .collect(); + *tn.go.tune_perf.lock().unwrap() = Value::Array(recs); + *tn.go.service_os_fail.lock().unwrap() = Some(("start".into(), "boum".into())); + tn.call("POST", "/api/tune", r#"{"stages":["aucune"]}"#) + .await; + let st = tn.wait("optimisation", "running").await; + let res = &st["result"]; + assert_eq!( + res["notes"], + json!([VRAM_NOTE, "⚠️ relance du moteur principal : boum"]), + "{res}" + ); + assert_eq!( + res["weights"], + json!({ "k": 1000, "g": 100, "f_cold": 1, "from": "télémétrie" }) + ); + assert!(!stale.exists(), "essais précédents effacés"); + // Enregistré AVANT la relance : sans sa note. + let saved: Value = serde_json::from_str(&tn.go.get("state", "tune_results").unwrap()).unwrap(); + assert_eq!(saved["dense"]["notes"], json!([VRAM_NOTE])); + *tn.go.service_os_fail.lock().unwrap() = None; + // Deux fois la RAM : la note. + tn.preset(&format!("{PRESET}MODEL_BYTES={}\n", total_mb << 21)); + tn.call("POST", "/api/tune", r#"{"stages":["aucune"]}"#) + .await; + let st = tn.wait("optimisation", "running").await; + assert_eq!( + st["result"]["notes"], + json!([ + VRAM_NOTE, + "modèle plus gros que la RAM : chaque essai le relit depuis le disque — prévoir un budget plus long, ou ne garder que quelques étapes" + ]) + ); +} + +/// Ce que la mesure dit d'un essai : un gain sous le seuil, un écart nul, +/// un decode en prose plus lent. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn ecarts_de_mesure() { + let tn = start().await; + // 512 refusé : la référence reste la meilleure ; 1024 à peine plus + // rapide ; 2048/4096 identique. + tn.preset(&format!("{PRESET}REFUS=512\nVITESSE=1024:1990\n")); + tn.call("POST", "/api/tune", r#"{"stages":["lots"]}"#).await; + let st = tn.wait("optimisation", "running").await; + let tr = trials(&st["result"]); + assert_eq!(tr.len(), 4, "{tr:?}"); + assert_eq!(tr[2].0, "UBATCH=1024 BATCH=2048"); + assert_eq!(tr[2].1, "mesuré"); + assert!( + tr[2].2.starts_with('−') && tr[2].2.ends_with(" %, sous le seuil de 3 %"), + "{tr:?}" + ); + assert_eq!( + st["result"]["trials"][2]["runs"].as_array().unwrap().len(), + 1 + ); + assert_eq!( + tr[3], + ( + "UBATCH=2048 BATCH=4096".into(), + "mesuré".into(), + "≈ 0 % (seuil 3.0 %)".into() + ) + ); + // Un prefill bien plus rapide, mais un decode en prose plus lent de 20 %. + tn.preset(&format!("{PRESET}REFUS=1024\nDECODE=512:40\n")); + tn.call("POST", "/api/tune", r#"{"stages":["lots"]}"#).await; + let st = tn.wait("decode", "running").await; + let tr = trials(&st["result"]); + assert_eq!( + tr[0], + ( + "UBATCH=512 BATCH=2048".into(), + "mesuré".into(), + "decode en prose plus lent de 20 %".into() + ), + "{tr:?}" + ); + assert_eq!(st["result"]["best"], Value::Null); +} + +/// L'application dans le preset, gardée : il doit être celui en service, et +/// pas une API externe ; une configuration changée ailleurs pendant +/// l'application la défait ; la sauvegarde est notée dans le verrou avant +/// toute écriture. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn application_gardee() { + let tn = start().await; + tn.call("POST", "/api/tune", r#"{"stages":["lots"]}"#).await; + let st = tn.wait("optimisation", "running").await; + assert_eq!(st["result"]["set"]["UBATCH"], json!("512"), "{st}"); + let old = std::fs::read_to_string(tn.home.join("presets/dense.env")).unwrap(); + + // La configuration active n'est plus ce preset. + tn.go.set("config", "UBATCH", "1024"); + let apply = tn.apply_preset().await; + assert_eq!( + apply["error"], + json!( + "ce preset n'est plus celui en service : bascule dessus, ou enregistre le résultat dans une copie" + ), + "{apply}" + ); + assert_eq!(apply["log"], Value::Null); + tn.go.set("config", "UBATCH", "2048"); + + // Une API externe. + tn.go.set("config", "EXTERNAL", "1"); + let apply = tn.apply_preset().await; + assert_eq!( + apply["error"], + json!("le preset actif est une API externe"), + "{apply}" + ); + tn.go.del("config", "EXTERNAL"); + + // Une clé changée ailleurs pendant l'application : l'ancienne version + // revient, sans relance sur la nouvelle. + tn.go + .apply_extra + .lock() + .unwrap() + .push(("THREADS".into(), "7".into())); + let apply = tn.apply_preset().await; + assert_eq!( + apply["error"], + json!( + "la configuration a changé ailleurs que sur les clés réglées depuis la mesure : ancienne version rétablie" + ), + "{apply}" + ); + assert_eq!( + std::fs::read_to_string(tn.home.join("presets/dense.env")).unwrap(), + old + ); + assert_eq!( + *tn.go.service_os.lock().unwrap(), + ["stop", "start", "restart"] + ); + tn.go.apply_extra.lock().unwrap().clear(); + tn.preset(PRESET); + + // Le moteur relancé tarde : pendant l'attente, le verrou désigne la + // sauvegarde (un Loki tué la remet au redémarrage). + *tn.main.down.lock().unwrap() = true; + let (code, _) = tn + .call("POST", "/api/tune/apply", r#"{"target":"preset"}"#) + .await; + assert_eq!(code, 202); + let mut noted = None; + for _ in 0..1000 { + if let Some(o) = loki_rs::tune::lock::read_owner(&tn.home.join("tune.lock")) + && !o.backup.is_empty() + { + noted = Some(o); + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + let o = noted.expect("sauvegarde jamais notée dans le verrou"); + assert_eq!( + ( + o.phase.as_str(), + o.preset_id.as_str(), + o.preset_name.as_str() + ), + ("application", "dense", "Dense") + ); + assert_eq!(std::fs::read_to_string(&o.backup).unwrap(), old); + *tn.main.down.lock().unwrap() = false; + let apply = tn.wait("application", "applying").await["apply"].clone(); + assert_eq!( + (apply["error"].clone(), apply["message"].clone()), + (json!(""), json!("preset « Dense » optimisé et vérifié")), + "{apply}" + ); + assert!(!tn.home.join("tune.lock").exists(), "verrou non rendu"); +} + +/// Un essai qui ne répond jamais : abandonné au bout du délai de chargement. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn essai_muet() { + let tn = start_with(|t, _| t.load_timeout = Duration::from_secs(2), |_, _| {}).await; + tn.preset(&format!("{PRESET}MUET=512\n")); + tn.call("POST", "/api/tune", r#"{"stages":["lots"]}"#).await; + let st = tn.wait("optimisation", "running").await; + let tr = trials(&st["result"]); + assert_eq!( + tr[0], + ( + "UBATCH=512 BATCH=2048".into(), + "échec".into(), + "pas prêt après 2s".into() + ), + "{tr:?}" + ); +} + +/// SIDE_SLOT sur un moteur à un seul slot : les mesures restent sur le +/// slot 0. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn second_slot_absent() { + let tn = start().await; + tn.preset(&format!("{PRESET}SIDE_SLOT=on\n")); + tn.call("POST", "/api/tune", r#"{"stages":["aucune"]}"#) + .await; + let st = tn.wait("optimisation", "running").await; + assert_eq!(st["error"], Value::Null, "{st}"); + assert_eq!(st["result"]["baseline"]["status"], json!("mesuré"), "{st}"); +} + +/// Les threads essayés : comptés sur les cœurs physiques que la sonde du +/// moteur annonce (des poids sur CPU). +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn threads_de_la_sonde() { + let tn = start().await; + tn.preset(&format!("{PRESET}EXTRA_ARGS=--cpu-moe\nCPU_THREADS=13\n")); + tn.call("POST", "/api/tune", r#"{"stages":["threads"]}"#) + .await; + let st = tn.wait("optimisation", "running").await; + let labels: Vec = trials(&st["result"]).into_iter().map(|t| t.0).collect(); + assert_eq!(labels, ["THREADS=12", "THREADS=6"], "{st}"); +}