harness_lock_core/
search.rs1use std::collections::BTreeMap;
4
5use serde_json::json;
6
7use crate::hash::content_hash;
8
9#[derive(Debug, Clone)]
11pub struct Stack {
12 pub llm: BTreeMap<String, String>, pub harness: String,
14 pub pattern: String,
15 pub decision: String,
16 pub tools: Vec<String>,
17 pub skills: Vec<String>,
18 pub instructions: Vec<String>,
19 pub max_steps: u32,
20 pub max_cost_usd_per_task: Option<f64>,
21 pub retries: u32,
25 pub temperature: Option<f64>,
27 pub tool_timeout_seconds: Option<u64>,
29}
30
31impl Default for Stack {
32 fn default() -> Self {
33 Stack {
34 llm: BTreeMap::new(),
35 harness: "reference".into(),
36 pattern: "single-agent".into(),
37 decision: "none".into(),
38 tools: Vec::new(),
39 skills: Vec::new(),
40 instructions: Vec::new(),
41 max_steps: 40,
42 max_cost_usd_per_task: None,
43 retries: 0,
44 temperature: None,
45 tool_timeout_seconds: None,
46 }
47 }
48}
49
50impl Stack {
51 pub fn label(&self) -> String {
52 let llm = self.llm.values().cloned().collect::<Vec<_>>().join("+");
53 format!("{}/{}/d={}", self.harness, llm, self.decision)
54 }
55
56 pub fn to_value(&self) -> serde_json::Value {
57 json!({
58 "llm": self.llm,
59 "harness": self.harness,
60 "pattern": self.pattern,
61 "decision": self.decision,
62 "tools": sorted_list(&self.tools),
63 "skills": sorted_list(&self.skills),
64 "instructions": sorted_list(&self.instructions),
65 "max_steps": self.max_steps,
66 "retries": self.retries,
67 "temperature": self.temperature,
68 "tool_timeout_seconds": self.tool_timeout_seconds,
69 })
70 }
71
72 pub fn hash(&self) -> String {
73 content_hash(&self.to_value())
74 }
75}
76
77fn sorted_list(v: &[String]) -> Vec<String> {
78 let mut c = v.to_vec();
79 c.sort();
80 c.dedup();
81 c
82}
83
84#[derive(Debug, Clone)]
86pub struct TrialResult {
87 pub stack_hash: String,
88 pub task_id: String,
89 pub seed: u64,
90 pub success: bool,
91 pub score: f64,
92 pub cost_usd: f64,
93 pub latency_s: f64,
94 pub steps: u64,
95}
96
97#[derive(Debug, Clone)]
99pub struct StackScore {
100 pub stack: Stack,
101 pub trials: Vec<TrialResult>,
102 pub n: usize,
103}
104
105impl StackScore {
106 pub fn success_rate(&self) -> f64 {
107 let ok = self.trials.iter().filter(|t| t.success).count();
108 ok as f64 / self.trials.len().max(1) as f64
109 }
110
111 pub fn mean_cost(&self) -> f64 {
112 let s: f64 = self.trials.iter().map(|t| t.cost_usd).sum();
113 s / self.trials.len().max(1) as f64
114 }
115
116 pub fn mean_latency(&self) -> f64 {
117 let s: f64 = self.trials.iter().map(|t| t.latency_s).sum();
118 s / self.trials.len().max(1) as f64
119 }
120
121 pub fn p95_latency(&self) -> f64 {
122 let mut v: Vec<f64> = self.trials.iter().map(|t| t.latency_s).collect();
123 v.sort_by(|a, b| a.partial_cmp(b).unwrap());
124 if v.is_empty() {
125 return 0.0;
126 }
127 let idx = (((v.len() as f64) * 0.95).ceil() as usize).min(v.len()) - 1;
128 v[idx]
129 }
130}
131
132fn dominates(a: (f64, f64), b: (f64, f64)) -> bool {
133 (a.0 >= b.0 && a.1 <= b.1) && (a.0 > b.0 || a.1 < b.1)
135}
136
137pub fn pareto_front(mut scores: Vec<StackScore>) -> Vec<StackScore> {
139 let pts: Vec<(f64, f64)> = scores
140 .iter()
141 .map(|s| (roundn(s.success_rate(), 6), roundn(s.mean_cost(), 8)))
142 .collect();
143 let mut front: Vec<StackScore> = Vec::new();
144 for (i, s) in scores.drain(..).enumerate() {
145 let dominated = pts
146 .iter()
147 .enumerate()
148 .any(|(j, o)| j != i && dominates(*o, pts[i]));
149 if !dominated {
150 front.push(s);
151 }
152 }
153 front.sort_by(|a, b| {
154 b.success_rate()
155 .partial_cmp(&a.success_rate())
156 .unwrap()
157 .then(a.mean_cost().partial_cmp(&b.mean_cost()).unwrap())
158 });
159 front
160}
161
162pub fn select_winner(scores: Vec<StackScore>, _primary: &str, constraints: &BTreeMap<String, String>, select: &str) -> Option<StackScore> {
164 let front = pareto_front(scores);
165 if front.is_empty() {
166 return None;
167 }
168 fn ok(s: &StackScore, constraints: &BTreeMap<String, String>) -> bool {
169 for (metric, expr) in constraints {
170 let val = match metric.as_str() {
171 "success-rate" => Some(s.success_rate()),
172 "cost-usd-per-task" => Some(s.mean_cost()),
173 _ => None,
174 };
175 if let Some(v) = val {
176 if let Some(op) = expr.strip_prefix(">=") {
177 if !(v >= op.trim().parse::<f64>().unwrap_or(-1.0)) {
178 return false;
179 }
180 } else if let Some(op) = expr.strip_prefix("<=") {
181 if !(v <= op.trim().parse::<f64>().unwrap_or(f64::MAX)) {
182 return false;
183 }
184 } else if let Some(op) = expr.strip_prefix('>') {
185 if !(v > op.trim().parse::<f64>().unwrap_or(-1.0)) {
186 return false;
187 }
188 } else if let Some(op) = expr.strip_prefix('<') {
189 if !(v < op.trim().parse::<f64>().unwrap_or(f64::MAX)) {
190 return false;
191 }
192 }
193 }
194 }
195 true
196 }
197 let eligible: Vec<StackScore> = front.iter().filter(|s| ok(s, constraints)).cloned().collect();
198 let pool = if eligible.is_empty() { front } else { eligible };
199 if pool.is_empty() {
200 return None;
201 }
202 match select {
203 "max-quality" => pool
204 .into_iter()
205 .max_by(|a, b| a.success_rate().partial_cmp(&b.success_rate()).unwrap()),
206 _ => pool.into_iter().min_by(|a, b| a.mean_cost().partial_cmp(&b.mean_cost()).unwrap()),
207 }
208}
209
210fn roundn(v: f64, n: i32) -> f64 {
211 let m = 10f64.powi(n);
212 (v * m).round() / m
213}