Skip to main content

harness_lock_core/
ops.rs

1//! Operational commands built on the resolver: `hlock upgrade` (§5.8) and
2//! `hlock tune` (§5.9, §8.5).
3//!
4//! - **upgrade** moves locked components to newer/breaking versions, keeping a
5//!   `harness.lock.prev` for `--rollback`, and gates the apply on evidence not
6//!   regressing.
7//! - **tune** is the compute-heavy step: it searches hyperparameter/decision
8//!   axes (and, at deeper depths, records prompt edits and fine-tuning) to produce
9//!   a derived `harness.tune.lock` (§5.9) that re-measures gains vs the base lock.
10
11use std::collections::BTreeMap;
12use std::path::PathBuf;
13
14use crate::error::{Error, Result};
15use crate::hash::sha256_text;
16use crate::lockfile::{Decision, Evidence, Harness, Lockfile};
17use crate::manifest::Manifest;
18use crate::search::Stack;
19use crate::resolver::Resolver;
20use crate::LOCKFILE_PATH;
21
22// --------------------------------------------------------------------------- //
23// Reference-model ladder (an upgrade graph, local/offline stand-in for real
24// registry version resolvers).
25// --------------------------------------------------------------------------- //
26pub const REFERENCE_MODELS: [&str; 3] = ["qwen-1.5b", "qwen-7b", "qwen-32b"];
27
28pub fn latest_model() -> &'static str {
29    "qwen-32b"
30}
31
32pub fn next_model(model: &str) -> Option<String> {
33    let idx = REFERENCE_MODELS.iter().position(|m| *m == model)?;
34    REFERENCE_MODELS.get(idx + 1).map(|s| s.to_string())
35}
36
37/// Resolve the target model for an upgrade of `model`.
38fn target_for(model: &str, to: Option<&str>, all: bool) -> Option<String> {
39    if let Some(t) = to {
40        return Some(t.to_string());
41    }
42    if all {
43        return Some(latest_model().to_string());
44    }
45    next_model(model)
46}
47
48// --------------------------------------------------------------------------- //
49// Upgrade
50// --------------------------------------------------------------------------- //
51#[derive(Debug, Clone, Default)]
52pub struct UpgradeSpec {
53    /// Explicit target model(s); only planner is downgrade/upgrade-able here.
54    pub to: Option<String>,
55    /// Upgrade every upgradeable role to the latest.
56    pub all: bool,
57    /// Perform the apply (default is plan/dry-run).
58    pub apply: bool,
59    /// Restore `harness.lock` from `harness.lock.prev`.
60    pub rollback: bool,
61    /// Skip the evidence gate (apply even if success-rate regresses).
62    pub force: bool,
63    /// Where `.harnesslock/` store + runs live.
64    pub workdir: PathBuf,
65    /// The lock to upgrade (default `./harness.lock`).
66    pub lock: Option<PathBuf>,
67}
68
69#[derive(Debug, Clone)]
70pub struct UpgradeOutcome {
71    pub plan: serde_json::Value,
72    pub applied: bool,
73    pub rolled_back: bool,
74    pub before: Option<f64>,
75    pub after: Option<f64>,
76    pub lock_path: PathBuf,
77    pub message: String,
78}
79
80
81/// Compute the upgrade plan (role -> from -> to) from a lock (or manifest).
82pub fn plan_upgrade(lock: Option<&Lockfile>, manifest: &Manifest, spec: &UpgradeSpec) -> serde_json::Value {
83    let mut roles: BTreeMap<String, String> = BTreeMap::new();
84    if let Some(l) = lock {
85        for llm in &l.llm {
86            roles.insert(llm.role.clone(), llm.model.clone());
87        }
88    } else if let Some(first) = manifest
89        .candidates
90        .llm
91        .get("planner")
92        .and_then(|c| c.any_of.first())
93    {
94        roles.insert("planner".to_string(), model_of(first));
95    }
96    let mut entries = Vec::new();
97    for (role, model) in &roles {
98        if let Some(t) = target_for(model, spec.to.as_deref(), spec.all) {
99            entries.push(serde_json::json!({"role": role, "from": model, "to": t}));
100        }
101    }
102    serde_json::json!({"upgradeable": entries})
103}
104
105fn model_of(spec: &str) -> String {
106    spec.rsplit_once(':').map(|(_, m)| m.to_string()).unwrap_or_else(|| spec.to_string())
107}
108
109/// `hlock upgrade`. `manifest` should be loaded from `harness.toml`.
110pub fn upgrade(manifest: Manifest, spec: &UpgradeSpec) -> Result<UpgradeOutcome> {
111    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
112    let lock_path = spec.lock.clone().unwrap_or_else(|| cwd.join(LOCKFILE_PATH));
113    let prev_path = PathBuf::from(format!("{}.prev", lock_path.display()));
114
115    let current_lock = Lockfile::load(&lock_path).ok();
116    let plan = plan_upgrade(current_lock.as_ref(), &manifest, spec);
117    let mut outcome = UpgradeOutcome {
118        plan: plan.clone(),
119        applied: false,
120        rolled_back: false,
121        before: current_lock.as_ref().map(baseline_sr),
122        after: None,
123        lock_path: lock_path.clone(),
124        message: String::new(),
125    };
126
127    // ---- rollback ----
128    if spec.rollback {
129        if prev_path.exists() {
130            std::fs::copy(&prev_path, &lock_path)?;
131            outcome.rolled_back = true;
132            outcome.message = "restored the previous harness.lock from harness.lock.prev".into();
133            outcome.after = Lockfile::load(&lock_path).ok().map(|l| baseline_sr(&l));
134            return Ok(outcome);
135        }
136        return Err(Error::Tuning("harness.lock.prev not found — nothing to roll back".into()));
137    }
138
139    // ---- plan only (dry-run) ----
140    let upgradeable = plan.get("upgradeable").and_then(serde_json::Value::as_array).cloned().unwrap_or_default();
141    if !spec.apply {
142        if upgradeable.is_empty() {
143            outcome.message = "nothing to upgrade (already at the latest).".into();
144        } else {
145            outcome.message = format!(
146                "plan: {} upgrade(s) available — rerun with --apply to perform.",
147                upgradeable.len()
148            );
149        }
150        return Ok(outcome);
151    }
152
153    if upgradeable.is_empty() {
154        outcome.message = "nothing to apply (no upgradeable component).".into();
155        return Ok(outcome);
156    }
157
158    // ---- apply: derive a manifest targeting the upgraded planner ----
159    let target = upgradeable[0].get("to").and_then(serde_json::Value::as_str).unwrap_or(latest_model()).to_string();
160    let spec_str = "reference:".to_string() + &target;
161    if lock_path.exists() {
162        std::fs::copy(&lock_path, &prev_path)?; // keep harness.lock.prev for --rollback
163    }
164    let mut derived = manifest.clone();
165    let mut cand = crate::manifest::Candidates::default();
166    cand.llm = manifest.candidates.llm.clone();
167    cand.llm
168        .entry("planner".to_string())
169        .and_modify(|c| c.any_of = vec![spec_str.clone()])
170        .or_insert(crate::manifest::LLMCandidate { any_of: vec![spec_str.clone()] });
171    cand.decision = manifest.candidates.decision.clone();
172    cand.harness = manifest.candidates.harness.clone();
173    cand.policy = manifest.candidates.policy.clone();
174    derived.candidates = cand;
175
176    let mut resolver = Resolver::new(derived, &spec.workdir);
177    let new_lock = resolver.resolve_lock()?;
178    new_lock.save(&lock_path)?;
179
180    let after = baseline_sr(&new_lock);
181    outcome.after = Some(after);
182    let before = outcome.before;
183    let regressed = before.map(|b| after < b).unwrap_or(false);
184    if regressed && !spec.force {
185        // evidence gate: roll back automatically
186        if prev_path.exists() {
187            std::fs::copy(&prev_path, &lock_path)?;
188        }
189        outcome.applied = false;
190        outcome.message = format!(
191            "upgrade regressed success-rate ({:?} -> {:.2}) and was rolled back; use --force to apply anyway.",
192            before, after
193        );
194        return Ok(outcome);
195    }
196    outcome.applied = true;
197    outcome.message = format!(
198        "upgraded planner to {target}: success-rate {:.2} -> {:.2}. previous lock kept at harness.lock.prev.",
199        before.unwrap_or(0.0),
200        after
201    );
202    Ok(outcome)
203}
204
205fn baseline_sr(l: &Lockfile) -> f64 {
206    l.evidence.success_rate.get("mean").copied().unwrap_or(0.0)
207}
208
209// --------------------------------------------------------------------------- //
210// Tune
211// --------------------------------------------------------------------------- //
212#[derive(Debug, Clone)]
213pub struct TuneSpec {
214    /// probe | prompt | hpo | ft | full | auto
215    pub depth: String,
216    /// "user" | "user:<paths>" | "synthetic[:n]" | "hub:<pack>"
217    pub data: String,
218    pub harness: Option<String>,
219    /// Output path (default `harness.tune.lock`; user-renamable).
220    pub out: PathBuf,
221    pub strict: bool,
222    pub apply: bool,
223    pub max_trials: Option<usize>,
224    /// local | devbox | cluster (overrides the manifest's `[backend] target`).
225    pub backend: Option<String>,
226    pub workdir: PathBuf,
227    /// The base lock (default `./harness.lock`); `apply` writes back to it.
228    pub lock: Option<PathBuf>,
229}
230
231impl Default for TuneSpec {
232    fn default() -> Self {
233        TuneSpec {
234            depth: "auto".into(),
235            data: "user".into(),
236            harness: None,
237            out: PathBuf::from("harness.tune.lock"),
238            strict: false,
239            apply: false,
240            max_trials: None,
241            backend: None,
242            workdir: PathBuf::from(".harnesslock"),
243            lock: None,
244        }
245    }
246}
247
248impl TuneSpec {
249    pub fn resolved_depth(&self) -> &str {
250        match self.depth.as_str() {
251            "auto" => "hpo",
252            other => other,
253        }
254    }
255}
256
257/// Grid axes searched by `tune`. Deeper depths broaden the grid.
258/// Returns `(model, retries, temperature, tool_timeout_seconds)`.
259fn search_grid(manifest: &Manifest, depth: &str) -> Vec<(String, u32, f64, Option<u64>)> {
260    let mut models: Vec<String> = manifest
261        .candidates
262        .llm
263        .get("planner")
264        .map(|c| c.any_of.iter().map(|s| model_of(s)).collect())
265        .unwrap_or_default();
266    if models.is_empty() {
267        models.push("qwen-7b".to_string());
268    }
269    if matches!(depth, "ft" | "full") && !models.contains(&latest_model().to_string()) {
270        models.push(latest_model().to_string()); // "fine-tune" may serve the best open model
271    }
272    let retries: Vec<u32> = match depth {
273        "probe" => vec![0],
274        "prompt" => vec![0, 1],
275        _ => vec![0, 1, 3], // hpo | ft | full
276    };
277    // temperature is an HPO axis only once we are doing hyperparameter search
278    let temps: Vec<f64> = if matches!(depth, "hpo" | "ft" | "full") {
279        vec![0.2, 0.7]
280    } else {
281        vec![0.2]
282    };
283    // tool timeout-seconds is a full-depth HPO axis
284    let timeouts: Vec<Option<u64>> = if depth == "full" {
285        vec![Some(30), Some(120)]
286    } else {
287        vec![Some(120)]
288    };
289    let mut out = Vec::new();
290    for m in &models {
291        for &r in &retries {
292            for &t in &temps {
293                for &tt in &timeouts {
294                    out.push((m.clone(), r, t, tt));
295                }
296            }
297        }
298    }
299    out
300}
301
302/// `hlock tune` -> `harness.tune.lock` (§5.9).
303///
304/// Runs the depth-aware pipeline: **data** -> (depth>=prompt) **prompt search** ->
305/// **HPO trials** -> (depth>=ft) **LoRA fine-tune** -> (depth==full) **holdout
306/// re-validation**, on the configured backend. On `cluster`/Union the heavy stages
307/// are lifted to real Flyte tasks by the Python worker (GPUs for fine-tuning,
308/// images, caching), and their provenance/digests are recorded in the tune lock.
309pub fn tune(manifest: Manifest, spec: &TuneSpec) -> Result<TuneOutcome> {
310    let depth = spec.resolved_depth().to_string();
311    let backend = crate::worker::Backend::parse(
312        spec.backend.as_deref().unwrap_or(manifest.backend_target().as_str()),
313    );
314    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
315    let lock_path = spec.lock.clone().unwrap_or_else(|| cwd.join(LOCKFILE_PATH));
316    let base_lock = Lockfile::load(&lock_path).ok();
317    let baseline_sr = base_lock.as_ref().map(baseline_sr).unwrap_or(0.0);
318
319    let mut resolver = crate::resolver::Resolver::with_backend(manifest.clone(), &spec.workdir, backend);
320    let search_tasks = resolver.load_packs();
321    if search_tasks.is_empty() {
322        return Err(Error::Tuning("no tasks loaded — cannot tune".into()));
323    }
324    let seeds = manifest.seeds();
325    let ws_root = spec.workdir.join("runs");
326    std::fs::create_dir_all(&ws_root)?;
327
328    let n_synthetic = parse_synthetic(&spec.data);
329    let data_mode = if n_synthetic > 0 { "synthetic" } else { "user-traces" };
330
331    let worker = if backend.needs_worker() {
332        Some(crate::worker::PythonWorker::new())
333    } else {
334        None
335    };
336    let plan_value = serde_json::json!({
337        "depth": depth,
338        "data": spec.data,
339        "data_mode": data_mode,
340        "harness": spec.harness,
341        "max_trials": spec.max_trials,
342        "axes": {
343            "retries": if depth == "probe" { serde_json::json!([0]) } else { serde_json::json!([0,1,3]) },
344            "temperature": if matches!(depth.as_str(), "hpo" | "ft" | "full") { serde_json::json!([0.2,0.7]) } else { serde_json::json!([0.2]) },
345            "tool_timeout_seconds": if depth == "full" { serde_json::json!([30,120]) } else { serde_json::json!([120]) },
346        },
347    });
348
349    // ---- stage 1: data (user traces or synthetic) ----
350    let mut data_meta = serde_json::json!({});
351    if let Some(w) = &worker {
352        let kind = if n_synthetic > 0 { "data.synthetic" } else { "data.load" };
353        let ws = ws_root.join("stage_data");
354        match stage_run(w, backend.as_str(), kind, &ws, &plan_value) {
355            Ok(v) => data_meta = v,
356            Err(Error::Worker(e)) => eprintln!("warn: tune data stage: {e}"),
357            Err(e) => return Err(e),
358        }
359    } else {
360        data_meta = serde_json::json!({ "_placeholder": true, "rows": 0 });
361    }
362
363    // ---- stage 2: prompt search (depth >= prompt) ----
364    let mut prompt_meta = serde_json::json!({});
365    let mut tuned_prompt: Vec<String> = Vec::new();
366    if prompt_depth(&depth) {
367        if let Some(w) = &worker {
368            let ws = ws_root.join("stage_prompt");
369            match stage_run(w, backend.as_str(), "prompt.search", &ws, &plan_value) {
370                Ok(v) => {
371                    if let Some(p) = v.get("best_prompt").and_then(serde_json::Value::as_str) {
372                        tuned_prompt = vec![p.to_string()];
373                    }
374                    prompt_meta = v;
375                }
376                Err(Error::Worker(e)) => eprintln!("warn: tune prompt stage: {e}"),
377                Err(e) => return Err(e),
378            }
379        }
380    }
381
382    // ---- stage 3: HPO trials over the grid ----
383    let mut scored: Vec<(String, u32, f64, Option<u64>, f64, f64, usize)> = Vec::new();
384    for (model, retries, temp, tto) in search_grid(&manifest, &depth) {
385        let stack = Stack {
386            llm: BTreeMap::from([("planner".to_string(), format!("reference:{model}"))]),
387            harness: spec.harness.clone().unwrap_or_else(|| "reference".into()),
388            pattern: "single-agent".into(),
389            decision: manifest.candidates.decision.first().cloned().unwrap_or_else(|| "none".into()),
390            tools: manifest.tools.keys().cloned().collect(),
391            skills: manifest.skills.keys().cloned().collect(),
392            instructions: tuned_prompt.clone(),
393            max_steps: 40,
394            max_cost_usd_per_task: None,
395            retries,
396            temperature: Some(temp),
397            tool_timeout_seconds: tto,
398        };
399        let mut ok = 0u64;
400        let mut n = 0u64;
401        let mut cost = 0.0;
402        let mut budget_hit = false;
403        for task in &search_tasks {
404            for seed in 0..seeds {
405                if let Some(max) = spec.max_trials {
406                    if n >= max as u64 {
407                        budget_hit = true;
408                        break;
409                    }
410                }
411                let tr = resolver.run_trial(&stack, task, seed, &ws_root);
412                if tr.success {
413                    ok += 1;
414                }
415                cost += tr.cost_usd;
416                n += 1;
417            }
418            if budget_hit {
419                break;
420            }
421        }
422        if n > 0 {
423            scored.push((model, retries, temp, tto, ok as f64 / n as f64, cost / n as f64, n as usize));
424        }
425    }
426
427    // pick best (highest success, then lowest cost)
428    let best = scored
429        .iter()
430        .max_by(|a, b| a.4.partial_cmp(&b.4).unwrap().then(b.5.partial_cmp(&a.5).unwrap()));
431    let Some((bm, br, btemp, btto, bs, bc, bn_trials)) = best.cloned() else {
432        return Err(Error::Tuning("tune produced no candidate".into()));
433    };
434
435    if spec.strict && bs <= baseline_sr {
436        return Err(Error::Tuning(format!(
437            "--strict: tuned success-rate {bs:.2} does not beat the base {baseline_sr:.2}"
438        )));
439    }
440
441    let winner_stack = Stack {
442        llm: BTreeMap::from([("planner".to_string(), format!("reference:{bm}"))]),
443        harness: spec.harness.clone().unwrap_or_else(|| "reference".into()),
444        pattern: "single-agent".into(),
445        decision: manifest.candidates.decision.first().cloned().unwrap_or_else(|| "none".into()),
446        tools: manifest.tools.keys().cloned().collect(),
447        skills: manifest.skills.keys().cloned().collect(),
448        instructions: tuned_prompt.clone(),
449        max_steps: 40,
450        max_cost_usd_per_task: None,
451        retries: br,
452        temperature: Some(btemp),
453        tool_timeout_seconds: btto,
454    };
455
456    // ---- stage 4: fine-tune (depth >= ft) ----
457    let mut ft_meta = serde_json::json!({});
458    if ft_depth(&depth) {
459        if let Some(w) = &worker {
460            let ws = ws_root.join("stage_ft");
461            let ft_plan = serde_json::json!({
462                "depth": depth,
463                "data": spec.data,
464                "data_mode": data_mode,
465                "trainset_digest": data_meta.get("digest").cloned().unwrap_or(serde_json::Value::Null),
466            });
467            match stage_run(w, backend.as_str(), "ft.lora", &ws, &ft_plan) {
468                Ok(v) => ft_meta = v,
469                Err(Error::Worker(e)) => eprintln!("warn: tune fine-tune stage: {e}"),
470                Err(e) => return Err(e),
471            }
472        }
473    }
474
475    // ---- stage 5: holdout re-validation (depth == full) ----
476    let (es, ec, en, esplit) = if depth == "full" {
477        let hold = resolver.holdout();
478        if hold.is_empty() {
479            (bs, bc, bn_trials as usize, "search".to_string())
480        } else {
481            let mut ok = 0u64;
482            let mut n = 0u64;
483            let mut cost = 0.0;
484            for task in hold {
485                for seed in 0..seeds {
486                    let tr = resolver.run_trial(&winner_stack, task, seed, &ws_root);
487                    if tr.success {
488                        ok += 1;
489                    }
490                    cost += tr.cost_usd;
491                    n += 1;
492                }
493            }
494            (ok as f64 / n.max(1) as f64, cost / (n as f64).max(1.0), n as usize, "holdout".to_string())
495        }
496    } else {
497        (bs, bc, bn_trials as usize, "search".to_string())
498    };
499
500    let tune_lock = build_tuned_lock(
501        &manifest,
502        winner_stack,
503        es,
504        ec,
505        &base_lock,
506        &data_mode,
507        &spec.data,
508        &depth,
509        scored.len(),
510        en,
511        backend.as_str(),
512        &prompt_meta,
513        &ft_meta,
514        &data_meta,
515        &esplit,
516    );
517
518    let out_path = &spec.out;
519    if let Some(parent) = out_path.parent() {
520        std::fs::create_dir_all(parent)?;
521    }
522    tune_lock.save(out_path)?;
523
524    if spec.apply {
525        tune_lock.save(&lock_path)?;
526    }
527
528    let report = serde_json::json!({
529        "depth": depth,
530        "data": spec.data,
531        "data_mode": data_mode,
532        "backend": backend.as_str(),
533        "eval_split": esplit,
534        "grid": scored.len(),
535        "best": {"model": bm, "retries": br, "temperature": btemp, "tool_timeout_seconds": btto, "success_rate": es, "cost_usd": ec, "trials": en},
536        "baseline_success_rate": baseline_sr,
537        "gain_success_rate": es - baseline_sr,
538        "path": out_path.to_string_lossy(),
539        "applied": spec.apply,
540    });
541    Ok(TuneOutcome {
542        path: out_path.clone(),
543        lock: tune_lock,
544        report,
545    })
546}
547
548pub struct TuneOutcome {
549    pub path: PathBuf,
550    pub lock: Lockfile,
551    pub report: serde_json::Value,
552}
553
554fn build_tuned_lock(
555    manifest: &Manifest,
556    stack: Stack,
557    success: f64,
558    cost: f64,
559    base: &Option<Lockfile>,
560    data_mode: &str,
561    data_arg: &str,
562    depth: &str,
563    grid_size: usize,
564    trials: usize,
565    backend: &str,
566    prompt_meta: &serde_json::Value,
567    ft_meta: &serde_json::Value,
568    data_meta: &serde_json::Value,
569    eval_split: &str,
570) -> Lockfile {
571    let mut success_map = BTreeMap::new();
572    success_map.insert("mean".to_string(), (success * 1e4).round() / 1e4);
573    let mut cost_map = BTreeMap::new();
574    cost_map.insert("mean".to_string(), (cost * 1e6).round() / 1e6);
575
576    // Prompt + fine-tune digests come from the real worker stages when they ran
577    // (prompt.search / ft.lora); only when a depth demands a stage but there was
578    // no worker (pure-local) do we fall back to a flagged placeholder.
579    let mut proof_prompt = Vec::new();
580    if prompt_depth(depth) {
581        let placeholder = prompt_meta
582            .get("_placeholder")
583            .map(|_| true)
584            .or_else(|| prompt_meta.get("best_prompt").map(|_| false))
585            .unwrap_or(true);
586        proof_prompt.push(serde_json::json!({
587            "component": "harness.system-prompt",
588            "before": str_field(prompt_meta, "before_digest").unwrap_or_else(|| sha256_text("base system prompt")),
589            "after": str_field(prompt_meta, "after_digest").unwrap_or_else(|| sha256_text("tuned system prompt (depth >= prompt)")),
590            "gain": {"success-rate": format!("{success:+.2}")},
591            "_placeholder": placeholder,
592        }));
593    }
594
595    let mut finetune = Vec::new();
596    if ft_depth(depth) {
597        let placeholder = ft_meta.get("adapter_digest").is_none()
598            || ft_meta.get("_placeholder").is_some();
599        finetune.push(serde_json::json!({
600            "target": "decision",
601            "base": str_field(ft_meta, "base_model").unwrap_or_else(|| "hf://hlock/decider-1.5b".into()),
602            "data": format!("traces://{data_arg}"),
603            "adapter": {"lora": str_field(ft_meta, "adapter_digest").unwrap_or_else(|| "store://sha256:PLAN".into())},
604            "samples": num_field(ft_meta, "samples").unwrap_or(0.0) as i64,
605            "loss": num_field(ft_meta, "loss").unwrap_or(0.0),
606            "_placeholder": placeholder,
607        }));
608    }
609
610    let base_lock_hash = base
611        .as_ref()
612        .map(|l| sha256_text(&serde_json::to_string(l).unwrap_or_default()))
613        .unwrap_or_default();
614
615    let mut lf = Lockfile::default();
616    lf.manifest_hash = Some(manifest.manifest_hash());
617    lf.base_lock = if base_lock_hash.is_empty() { None } else { Some(base_lock_hash) };
618    lf.kind = Some("tuned".to_string());
619    let mut stack_map = BTreeMap::new();
620    stack_map.insert("name".to_string(), serde_json::json!(manifest.name()));
621    stack_map.insert("selection".to_string(), serde_json::json!("tune"));
622    lf.stack = stack_map;
623    lf.decision = Decision {
624        kind: stack.decision.clone(),
625        decisions: if stack.decision == "none" { vec![] } else { vec!["retry".into(), "stop".into()] },
626        ..Default::default()
627    };
628    lf.harness = Harness { name: stack.harness.clone(), pattern: Some(stack.pattern.clone()), ..Default::default() };
629    lf.llm = vec![crate::lockfile::LLM {
630        role: "planner".into(),
631        provider: "reference".into(),
632        model: stack.llm.get("planner").cloned().unwrap_or_default(),
633        resolved_from: None,
634        params: BTreeMap::from([
635            ("retries".to_string(), serde_json::json!(stack.retries)),
636            ("temperature".to_string(), serde_json::json!(stack.temperature.unwrap_or(0.2))),
637            ("tool_timeout_seconds".to_string(), serde_json::json!(stack.tool_timeout_seconds.unwrap_or(120))),
638        ]),
639        fingerprint: None,
640        ..Default::default()
641    }];
642    lf.policy = crate::lockfile::Policy {
643        retries: stack.retries,
644        max_steps: 40,
645        human_approval: "never".into(),
646        ..Default::default()
647    };
648    lf.evidence = Evidence {
649        eval_pack: Some(manifest.eval_packs().join(",")),
650        split: eval_split.into(),
651        trials,
652        success_rate: success_map,
653        cost_usd_per_task: cost_map,
654        ..Default::default()
655    };
656
657    // data provenance (real digest/rows/split when a worker stage ran)
658    let mut data_block = serde_json::json!({"source": data_arg, "mode": data_mode, "redact": true});
659    if let Some(d) = data_meta.get("digest").and_then(serde_json::Value::as_str) {
660        data_block["digest"] = serde_json::json!(d);
661    }
662    if let Some(v) = data_meta.get("rows") {
663        data_block["rows"] = v.clone();
664    }
665    if let Some(v) = data_meta.get("split") {
666        data_block["split"] = v.clone();
667    }
668    if data_meta.get("_placeholder").is_some() {
669        data_block["_placeholder"] = serde_json::json!(true);
670    }
671
672    // provenance: union run ids + budget (gpu-h / spend) aggregated across stages
673    let mut run_ids: Vec<String> = Vec::new();
674    for m in [prompt_meta, ft_meta, data_meta] {
675        if let Some(r) = m.get("run").and_then(serde_json::Value::as_str) {
676            run_ids.push(r.to_string());
677        }
678    }
679    let run = if run_ids.is_empty() {
680        format!("worker://{backend}")
681    } else {
682        run_ids.join(",")
683    };
684    let stage_gpu: f64 = [prompt_meta, ft_meta, data_meta]
685        .iter()
686        .map(|m| num_field(m, "gpu_h").unwrap_or(0.0))
687        .sum();
688    let stage_spend: f64 = [prompt_meta, ft_meta, data_meta]
689        .iter()
690        .map(|m| num_field(m, "cost_usd").unwrap_or(0.0))
691        .sum();
692    let spend = cost * trials as f64 + stage_spend;
693
694    lf.tune = Some(serde_json::json!({
695        "hpo": {
696            "axes": {
697                "retries": [0,1,3],
698                "temperature": [0.2,0.7],
699                "tool_timeout_seconds": [30,120],
700            },
701            "trials": grid_size,
702            "best": {"model": stack.llm.get("planner"), "retries": stack.retries, "temperature": stack.temperature, "tool_timeout_seconds": stack.tool_timeout_seconds},
703        },
704        "prompt": {"applied": proof_prompt},
705        "finetune": finetune,
706        "data": data_block,
707        "provenance": {"run": run, "budget": {"spend-usd": round6(spend), "gpu-h": round6(stage_gpu)}},
708    }));
709    lf
710}
711
712fn round6(v: f64) -> f64 {
713    (v * 1e6).round() / 1e6
714}
715
716fn str_field(v: &serde_json::Value, key: &str) -> Option<String> {
717    v.get(key).and_then(serde_json::Value::as_str).map(|s| s.to_string())
718}
719
720fn num_field(v: &serde_json::Value, key: &str) -> Option<f64> {
721    v.get(key).and_then(serde_json::Value::as_f64)
722}
723
724/// Depth ladder: does `depth` include prompt search?
725fn prompt_depth(depth: &str) -> bool {
726    matches!(depth, "prompt" | "hpo" | "ft" | "full")
727}
728
729/// Depth ladder: does `depth` include fine-tuning?
730fn ft_depth(depth: &str) -> bool {
731    matches!(depth, "ft" | "full")
732}
733
734/// Run a single stage against the Python worker and normalize its metadata.
735fn stage_run(
736    worker: &crate::worker::PythonWorker,
737    backend: &str,
738    kind: &str,
739    ws: &PathBuf,
740    plan: &serde_json::Value,
741) -> Result<serde_json::Value> {
742    let r = worker.call(kind, backend, None, None, 0, ws, plan)?;
743    let mut m = r.model_digests;
744    if m.is_null() {
745        m = serde_json::json!({});
746    }
747    if let Some(o) = m.as_object_mut() {
748        o.insert("gpu_h".into(), serde_json::json!(r.gpu_h));
749        o.insert("cost_usd".into(), serde_json::json!(r.cost_usd));
750    }
751    Ok(m)
752}
753
754fn parse_synthetic(data: &str) -> usize {
755    match data.strip_prefix("synthetic") {
756        None => 0,
757        Some("") => 1,
758        Some(rest) => rest.strip_prefix(':').and_then(|n| n.trim().parse().ok()).unwrap_or(1),
759    }
760}