1use sim_codec_bridge::{
2 ask_profile_symbol, brief_profile_symbol, collab_profile_symbol, loom_profile_symbol,
3};
4use sim_kernel::{Cx, Error, Expr, NumberLiteral, Result, Symbol};
5use sim_value::build::entry;
6
7use crate::{CompiledIntent, Verifier, VerifyCatalog};
8
9#[derive(Clone, Debug, PartialEq)]
11pub struct EvalPlayback {
12 pub answer: Expr,
14 pub tokens: u64,
16}
17
18impl EvalPlayback {
19 pub fn new(answer: Expr, tokens: u64) -> Self {
21 Self { answer, tokens }
22 }
23}
24
25#[derive(Clone, Debug, PartialEq)]
27pub struct EvalCassette {
28 pub compiler_tokens: u64,
30 pub raw: EvalPlayback,
32 pub compiled: EvalPlayback,
34 pub downshifted: EvalPlayback,
36}
37
38impl EvalCassette {
39 pub fn new(
41 compiler_tokens: u64,
42 raw: EvalPlayback,
43 compiled: EvalPlayback,
44 downshifted: EvalPlayback,
45 ) -> Self {
46 Self {
47 compiler_tokens,
48 raw,
49 compiled,
50 downshifted,
51 }
52 }
53}
54
55#[derive(Clone, Debug, PartialEq)]
57pub struct EvalCase {
58 pub name: Symbol,
60 pub profile: Symbol,
62 pub prose: String,
64 pub args: Expr,
66 pub gold_answer: Expr,
68 pub verifiers: Vec<Symbol>,
70 pub cassette: EvalCassette,
72}
73
74impl EvalCase {
75 pub fn new(
77 name: Symbol,
78 profile: Symbol,
79 prose: impl Into<String>,
80 args: Expr,
81 gold_answer: Expr,
82 verifiers: Vec<Symbol>,
83 cassette: EvalCassette,
84 ) -> Self {
85 Self {
86 name,
87 profile,
88 prose: prose.into(),
89 args,
90 gold_answer,
91 verifiers,
92 cassette,
93 }
94 }
95}
96
97#[derive(Clone, Debug, PartialEq, Eq)]
99pub struct EvalArm {
100 pub name: String,
102 pub compiled: bool,
104 pub cached: bool,
106 pub downshift: bool,
108 pub replay: bool,
110}
111
112impl EvalArm {
113 pub fn raw_prose(name: impl Into<String>) -> Self {
115 Self {
116 name: name.into(),
117 compiled: false,
118 cached: false,
119 downshift: false,
120 replay: false,
121 }
122 }
123
124 pub fn compiled_uncached(name: impl Into<String>) -> Self {
126 Self {
127 name: name.into(),
128 compiled: true,
129 cached: false,
130 downshift: false,
131 replay: false,
132 }
133 }
134
135 pub fn compiled_cached(name: impl Into<String>) -> Self {
137 Self {
138 name: name.into(),
139 compiled: true,
140 cached: true,
141 downshift: false,
142 replay: false,
143 }
144 }
145
146 pub fn compiled_cached_downshifted(name: impl Into<String>) -> Self {
148 Self {
149 name: name.into(),
150 compiled: true,
151 cached: true,
152 downshift: true,
153 replay: false,
154 }
155 }
156
157 pub fn identical_request_replay(name: impl Into<String>) -> Self {
159 Self {
160 name: name.into(),
161 compiled: true,
162 cached: true,
163 downshift: false,
164 replay: true,
165 }
166 }
167}
168
169#[derive(Clone, Debug, Default, PartialEq)]
171pub struct ArmMetrics {
172 pub accuracy: f64,
174 pub tokens: u64,
176 pub compiler_calls: u64,
178 pub execution_calls: u64,
180 pub replay_hits: u64,
182}
183
184#[derive(Clone, Debug, Default, PartialEq)]
186pub struct EvalReport {
187 pub arms: Vec<(String, ArmMetrics)>,
189}
190
191impl EvalReport {
192 pub fn metrics(&self, name: &str) -> Option<&ArmMetrics> {
194 self.arms
195 .iter()
196 .find_map(|(arm, metrics)| (arm == name).then_some(metrics))
197 }
198}
199
200pub fn standard_eval_arms() -> Vec<EvalArm> {
202 vec![
203 EvalArm::raw_prose("raw-prose-baseline"),
204 EvalArm::compiled_uncached("compiled-uncached"),
205 EvalArm::compiled_cached("compiled-cached"),
206 EvalArm::compiled_cached_downshifted("compiled-cached-downshifted"),
207 EvalArm::identical_request_replay("identical-request-replay"),
208 ]
209}
210
211pub fn standard_eval_corpus() -> Vec<EvalCase> {
213 vec![
214 corpus_case(CorpusSpec {
215 id: "brief-status",
216 profile: brief_profile_symbol(),
217 prose: "Summarize the release status in one word.",
218 args: Expr::String("release status is green".to_owned()),
219 gold_answer: Expr::String("green".to_owned()),
220 tokens: TokenSpec::new(42, 118, 64, 31),
221 }),
222 corpus_case(CorpusSpec {
223 id: "ask-count",
224 profile: ask_profile_symbol(),
225 prose: "Count the accepted review checks.",
226 args: Expr::Vector(vec![int(1), int(1), int(1), int(1)]),
227 gold_answer: int(4),
228 tokens: TokenSpec::new(36, 104, 58, 28),
229 }),
230 corpus_case(CorpusSpec {
231 id: "loom-plan",
232 profile: loom_profile_symbol(),
233 prose: "Order the two rollout rows.",
234 args: Expr::Vector(vec![
235 Expr::String("audit".to_owned()),
236 Expr::String("ship".to_owned()),
237 ]),
238 gold_answer: Expr::Vector(vec![
239 Expr::String("audit".to_owned()),
240 Expr::String("ship".to_owned()),
241 ]),
242 tokens: TokenSpec::new(48, 132, 72, 36),
243 }),
244 corpus_case(CorpusSpec {
245 id: "collab-vote",
246 profile: collab_profile_symbol(),
247 prose: "Approve only if the verifier passes.",
248 args: Expr::Bool(true),
249 gold_answer: Expr::Bool(true),
250 tokens: TokenSpec::new(44, 126, 66, 33),
251 }),
252 ]
253}
254
255pub fn run_eval(cx: &mut Cx, corpus: &[EvalCase], arms: &[EvalArm]) -> Result<EvalReport> {
257 if corpus.is_empty() {
258 return Err(Error::Eval("eval corpus is empty".to_owned()));
259 }
260 if arms.is_empty() {
261 return Err(Error::Eval("eval arms are empty".to_owned()));
262 }
263
264 let mut report = EvalReport { arms: Vec::new() };
265 for arm in arms {
266 let mut metrics = ArmMetrics::default();
267 let mut passed = 0_u64;
268 for case in corpus {
269 let outcome = run_case(cx, case, arm)?;
270 metrics.tokens = metrics.tokens.saturating_add(outcome.tokens);
271 metrics.compiler_calls = metrics
272 .compiler_calls
273 .saturating_add(outcome.compiler_calls);
274 metrics.execution_calls = metrics
275 .execution_calls
276 .saturating_add(outcome.execution_calls);
277 metrics.replay_hits = metrics.replay_hits.saturating_add(outcome.replay_hits);
278 if outcome.passed {
279 passed = passed.saturating_add(1);
280 }
281 }
282 metrics.accuracy = passed as f64 / corpus.len() as f64;
283 report.arms.push((arm.name.clone(), metrics));
284 }
285 Ok(report)
286}
287
288struct CaseOutcome {
289 passed: bool,
290 tokens: u64,
291 compiler_calls: u64,
292 execution_calls: u64,
293 replay_hits: u64,
294}
295
296fn run_case(cx: &mut Cx, case: &EvalCase, arm: &EvalArm) -> Result<CaseOutcome> {
297 if case.verifiers.is_empty() {
298 return Err(Error::Eval(format!(
299 "eval case {} declares no verifiers",
300 case.name
301 )));
302 }
303
304 let mut tokens = 0_u64;
305 let mut compiler_calls = 0_u64;
306 let mut execution_calls = 0_u64;
307 let mut replay_hits = 0_u64;
308 if arm.compiled && !arm.cached && !arm.replay {
309 compiler_calls = 1;
310 tokens = tokens.saturating_add(case.cassette.compiler_tokens);
311 }
312
313 let answer = if arm.replay {
314 replay_hits = 1;
315 &case.cassette.compiled.answer
316 } else {
317 execution_calls = 1;
318 let playback = playback_for(case, arm);
319 tokens = tokens.saturating_add(playback.tokens);
320 &playback.answer
321 };
322
323 Ok(CaseOutcome {
324 passed: verify_case(cx, case, answer)?,
325 tokens,
326 compiler_calls,
327 execution_calls,
328 replay_hits,
329 })
330}
331
332fn playback_for<'a>(case: &'a EvalCase, arm: &EvalArm) -> &'a EvalPlayback {
333 if !arm.compiled {
334 &case.cassette.raw
335 } else if arm.downshift {
336 &case.cassette.downshifted
337 } else {
338 &case.cassette.compiled
339 }
340}
341
342fn verify_case(cx: &mut Cx, case: &EvalCase, answer: &Expr) -> Result<bool> {
343 let mut catalog = VerifyCatalog::new();
344 for verifier in &case.verifiers {
345 catalog.register_verifier(
346 verifier.clone(),
347 Verifier::Assertion {
348 predicate: equals_predicate(case.gold_answer.clone()),
349 },
350 );
351 }
352 let intent = CompiledIntent {
353 name: case.name.clone(),
354 verifiers: case.verifiers.clone(),
355 ..CompiledIntent::default()
356 };
357 Ok(catalog.verify_answer(cx, &intent, answer)?.accepted())
358}
359
360fn equals_predicate(expected: Expr) -> Expr {
361 Expr::Map(vec![
362 entry(
363 "predicate",
364 Expr::Symbol(Symbol::qualified("forge", "equals")),
365 ),
366 entry("expected", expected),
367 ])
368}
369
370struct CorpusSpec {
371 id: &'static str,
372 profile: Symbol,
373 prose: &'static str,
374 args: Expr,
375 gold_answer: Expr,
376 tokens: TokenSpec,
377}
378
379struct TokenSpec {
380 compiler: u64,
381 raw: u64,
382 compiled: u64,
383 downshifted: u64,
384}
385
386impl TokenSpec {
387 fn new(compiler: u64, raw: u64, compiled: u64, downshifted: u64) -> Self {
388 Self {
389 compiler,
390 raw,
391 compiled,
392 downshifted,
393 }
394 }
395}
396
397fn corpus_case(spec: CorpusSpec) -> EvalCase {
398 EvalCase::new(
399 Symbol::qualified("forge/eval", spec.id),
400 spec.profile,
401 spec.prose,
402 spec.args,
403 spec.gold_answer.clone(),
404 vec![Symbol::qualified(
405 "forge/eval",
406 format!("{}-gold", spec.id).as_str(),
407 )],
408 EvalCassette::new(
409 spec.tokens.compiler,
410 EvalPlayback::new(spec.gold_answer.clone(), spec.tokens.raw),
411 EvalPlayback::new(spec.gold_answer.clone(), spec.tokens.compiled),
412 EvalPlayback::new(spec.gold_answer, spec.tokens.downshifted),
413 ),
414 )
415}
416
417fn int(value: i64) -> Expr {
418 Expr::Number(NumberLiteral {
419 domain: Symbol::qualified("number", "i64"),
420 canonical: value.to_string(),
421 })
422}