1use std::collections::HashMap;
2use std::sync::Arc;
3
4use algocline_core::pkg::PkgType;
5
6use super::eval_store::{
7 escape_for_lua_sq, evals_dir, extract_strategy_from_id, list_eval_history, save_compare_result,
8 save_eval_result, splice_response_string,
9};
10use super::path::ContainedPath;
11use super::resolve::{is_package_installed, resolve_scenario_code};
12use super::run::normalize_stringified_json_object;
13use super::AppService;
14
15impl AppService {
16 pub async fn eval(
30 &self,
31 scenario: Option<String>,
32 scenario_file: Option<String>,
33 scenario_name: Option<String>,
34 strategy: &str,
35 strategy_opts: Option<serde_json::Value>,
36 auto_card: bool,
37 ) -> Result<String, String> {
38 let app_dir = self.log_config.app_dir();
40 if !is_package_installed(&app_dir, "evalframe") {
41 self.auto_install_bundled_packages().await?;
42 if !is_package_installed(&app_dir, "evalframe") {
43 return Err(
44 "Package 'evalframe' not found after installing bundled collection. \
45 Use alc_pkg_install to install it manually."
46 .into(),
47 );
48 }
49 }
50
51 if let Some((PkgType::Library, _)) = self.resolve_pkg_type_lua(strategy, &[]).await? {
53 return Err(format!(
54 "Package '{strategy}' is a library package (type = \"library\"). \
55 Library packages cannot be evaluated as strategies. \
56 Use a runnable package instead."
57 ));
58 }
59
60 let scenario_code =
61 resolve_scenario_code(&app_dir, scenario, scenario_file, scenario_name.clone())?;
62
63 let strategy_opts = strategy_opts.map(normalize_stringified_json_object);
65 let opts_lua = match &strategy_opts {
66 Some(v) if !v.is_null() => format!("alc.json_decode('{}')", v),
67 _ => "nil".to_string(),
68 };
69
70 let auto_card_lua = if auto_card { "true" } else { "false" };
71
72 let wrapped = format!(
78 r#"local scenario = (function()
79{scenario_code}
80end)()
81
82return alc.eval(scenario, "{strategy}", {{
83 strategy_opts = {opts_lua},
84 auto_card = {auto_card_lua},
85}})
86"#,
87 );
88
89 let ctx = serde_json::Value::Null;
90 let env_map = Arc::new(HashMap::new());
93 let result = self
94 .start_and_tick(env_map, wrapped, ctx, Some(strategy), vec![], vec![])
95 .await?;
96
97 let mut save_warning: Option<String> = None;
100 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&result) {
101 match parsed.get("status").and_then(|s| s.as_str()) {
102 Some("completed") => {
103 if let Err(e) = save_eval_result(&app_dir, strategy, &result) {
104 save_warning = Some(e);
105 }
106 }
107 Some("needs_response") => {
108 if let Some(sid) = parsed.get("session_id").and_then(|s| s.as_str()) {
109 if let Ok(mut map) = self.eval_sessions.lock() {
110 map.insert(sid.to_string(), strategy.to_string());
111 }
112 }
113 }
114 _ => {}
115 }
116 }
117
118 match save_warning {
119 Some(msg) => Ok(splice_response_string(&result, "save_warning", &msg)),
120 None => Ok(result),
121 }
122 }
123
124 pub fn eval_history(&self, strategy: Option<&str>, limit: usize) -> Result<String, String> {
126 let dir = evals_dir(&self.log_config.app_dir());
127 list_eval_history(&dir, strategy, limit)
128 }
129
130 pub fn eval_detail(&self, eval_id: &str) -> Result<String, String> {
132 let evals_dir = evals_dir(&self.log_config.app_dir());
133 let path = ContainedPath::child(&evals_dir, &format!("{eval_id}.json"))
134 .map_err(|e| format!("Invalid eval_id: {e}"))?;
135 if !path.exists() {
136 return Err(format!("Eval result not found: {eval_id}"));
137 }
138 std::fs::read_to_string(&*path).map_err(|e| format!("Failed to read eval: {e}"))
139 }
140
141 pub async fn eval_compare(&self, eval_id_a: &str, eval_id_b: &str) -> Result<String, String> {
150 let app_dir = self.log_config.app_dir();
151 let cache_filename = format!("compare_{eval_id_a}_vs_{eval_id_b}.json");
153 {
154 let dir = evals_dir(&app_dir);
155 if let Ok(cached_path) = ContainedPath::child(&dir, &cache_filename) {
156 if cached_path.exists() {
157 return std::fs::read_to_string(&*cached_path)
158 .map_err(|e| format!("Failed to read cached comparison: {e}"));
159 }
160 }
161 }
162
163 if !is_package_installed(&app_dir, "evalframe") {
165 self.auto_install_bundled_packages().await?;
166 if !is_package_installed(&app_dir, "evalframe") {
167 return Err(
168 "Package 'evalframe' not found after installing bundled collection. \
169 Use alc_pkg_install to install it manually."
170 .into(),
171 );
172 }
173 }
174
175 let result_a = self.eval_detail(eval_id_a)?;
176 let result_b = self.eval_detail(eval_id_b)?;
177
178 let lua_code = format!(
181 r#"local stats = require("evalframe.eval.stats")
182
183local result_a = alc.json_decode('{result_a_escaped}')
184local result_b = alc.json_decode('{result_b_escaped}')
185
186local agg_a = result_a.result and result_a.result.aggregated
187local agg_b = result_b.result and result_b.result.aggregated
188
189if not agg_a or not agg_a.scores then
190 error("No aggregated scores in {eval_id_a_escaped}")
191end
192if not agg_b or not agg_b.scores then
193 error("No aggregated scores in {eval_id_b_escaped}")
194end
195
196local welch = stats.welch_t(agg_a.scores, agg_b.scores)
197
198local strategy_a = (result_a.result and result_a.result.name) or "{strategy_a_fallback}"
199local strategy_b = (result_b.result and result_b.result.name) or "{strategy_b_fallback}"
200
201local delta = agg_a.scores.mean - agg_b.scores.mean
202local winner = "none"
203if welch.significant then
204 winner = delta > 0 and "a" or "b"
205end
206
207-- Build summary text
208local parts = {{}}
209if welch.significant then
210 local w, l, d = strategy_a, strategy_b, delta
211 if delta < 0 then w, l, d = strategy_b, strategy_a, -delta end
212 parts[#parts + 1] = string.format(
213 "%s outperforms %s by %.4f (mean score), statistically significant (t=%.3f, df=%.1f).",
214 w, l, d, math.abs(welch.t_stat), welch.df
215 )
216else
217 parts[#parts + 1] = string.format(
218 "No statistically significant difference between %s and %s (t=%.3f, df=%.1f).",
219 strategy_a, strategy_b, math.abs(welch.t_stat), welch.df
220 )
221end
222if agg_a.pass_rate and agg_b.pass_rate then
223 local dp = agg_a.pass_rate - agg_b.pass_rate
224 if math.abs(dp) > 1e-9 then
225 local h = dp > 0 and strategy_a or strategy_b
226 parts[#parts + 1] = string.format("Pass rate: %s +%.1fpp.", h, math.abs(dp) * 100)
227 else
228 parts[#parts + 1] = string.format("Pass rate: identical (%.1f%%).", agg_a.pass_rate * 100)
229 end
230end
231
232return {{
233 a = {{
234 eval_id = "{eval_id_a_escaped}",
235 strategy = strategy_a,
236 scores = agg_a.scores,
237 pass_rate = agg_a.pass_rate,
238 pass_at_1 = agg_a.pass_at_1,
239 ci_95 = agg_a.ci_95,
240 }},
241 b = {{
242 eval_id = "{eval_id_b_escaped}",
243 strategy = strategy_b,
244 scores = agg_b.scores,
245 pass_rate = agg_b.pass_rate,
246 pass_at_1 = agg_b.pass_at_1,
247 ci_95 = agg_b.ci_95,
248 }},
249 comparison = {{
250 delta_mean = delta,
251 welch_t = {{
252 t_stat = welch.t_stat,
253 df = welch.df,
254 significant = welch.significant,
255 direction = welch.direction,
256 }},
257 winner = winner,
258 summary = table.concat(parts, " "),
259 }},
260}}
261"#,
262 result_a_escaped = escape_for_lua_sq(&result_a),
263 result_b_escaped = escape_for_lua_sq(&result_b),
264 eval_id_a_escaped = escape_for_lua_sq(eval_id_a),
271 eval_id_b_escaped = escape_for_lua_sq(eval_id_b),
272 strategy_a_fallback =
273 escape_for_lua_sq(extract_strategy_from_id(eval_id_a).unwrap_or("A")),
274 strategy_b_fallback =
275 escape_for_lua_sq(extract_strategy_from_id(eval_id_b).unwrap_or("B")),
276 );
277
278 let ctx = serde_json::Value::Null;
279 let env_map = Arc::new(HashMap::new());
282 let raw_result = self
283 .start_and_tick(env_map, lua_code, ctx, None, vec![], vec![])
284 .await?;
285
286 match save_compare_result(&app_dir, eval_id_a, eval_id_b, &raw_result) {
290 Ok(()) => Ok(raw_result),
291 Err(e) => Ok(splice_response_string(&raw_result, "save_warning", &e)),
292 }
293 }
294}