1use 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
22pub 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
37fn 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#[derive(Debug, Clone, Default)]
52pub struct UpgradeSpec {
53 pub to: Option<String>,
55 pub all: bool,
57 pub apply: bool,
59 pub rollback: bool,
61 pub force: bool,
63 pub workdir: PathBuf,
65 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
81pub 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
109pub 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 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 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 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() + ⌖
161 if lock_path.exists() {
162 std::fs::copy(&lock_path, &prev_path)?; }
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 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#[derive(Debug, Clone)]
213pub struct TuneSpec {
214 pub depth: String,
216 pub data: String,
218 pub harness: Option<String>,
219 pub out: PathBuf,
221 pub strict: bool,
222 pub apply: bool,
223 pub max_trials: Option<usize>,
224 pub backend: Option<String>,
226 pub workdir: PathBuf,
227 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
257fn 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()); }
272 let retries: Vec<u32> = match depth {
273 "probe" => vec![0],
274 "prompt" => vec![0, 1],
275 _ => vec![0, 1, 3], };
277 let temps: Vec<f64> = if matches!(depth, "hpo" | "ft" | "full") {
279 vec![0.2, 0.7]
280 } else {
281 vec![0.2]
282 };
283 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
302pub 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 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 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 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 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 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 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 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 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 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
724fn prompt_depth(depth: &str) -> bool {
726 matches!(depth, "prompt" | "hpo" | "ft" | "full")
727}
728
729fn ft_depth(depth: &str) -> bool {
731 matches!(depth, "ft" | "full")
732}
733
734fn 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}