Skip to main content

harness_lock_core/
search.rs

1//! The search layer: stacks, trials, aggregation, Pareto front, selection.
2
3use std::collections::BTreeMap;
4
5use serde_json::json;
6
7use crate::hash::content_hash;
8
9/// One concrete agent configuration (an axis of the search space).
10#[derive(Debug, Clone)]
11pub struct Stack {
12    pub llm: BTreeMap<String, String>, // role -> "provider:model" spec
13    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    /// Number of agent-level retries (a policy under test; the reference harness
22    /// retries a "failed" step up to this many times, so more retries raise
23    /// success at the cost of more tokens/latency).
24    pub retries: u32,
25    /// Sampling temperature (recorded; the reference harness is deterministic).
26    pub temperature: Option<f64>,
27    /// Per-step tool timeout in seconds (an HPO axis at deeper depths).
28    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/// One executed trial.
85#[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/// Aggregated metrics for one stack.
98#[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    // (quality, cost); a dominates b if >= quality, <= cost and strictly better in one
134    (a.0 >= b.0 && a.1 <= b.1) && (a.0 > b.0 || a.1 < b.1)
135}
136
137/// Non-dominated stacks on (quality, cost). Quality higher is better.
138pub 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
162/// Select winner from the Pareto front honoring objective constraints + select rule.
163pub 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}