Coverage for agentos/evaluation/regression.py: 36%

151 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-06 10:59 +0800

1"""Evaluation regression testing for AgentOS. 

2 

3Compare evaluation runs, detect regressions, generate CI artifacts. 

4Builds on top of agentos.evaluation core (GoldenDataset, Evaluator, EvalReport). 

5""" 

6 

7from __future__ import annotations 

8 

9from dataclasses import dataclass, field 

10from typing import Dict, List, Optional, Tuple 

11import json 

12import math 

13import xml.etree.ElementTree as ET 

14 

15from agentos.evaluation import Evaluator, EvalReport, ScoreDetail 

16 

17 

18@dataclass 

19class RegressionCheck: 

20 """A single regression check result.""" 

21 case_id: str 

22 baseline_score: float 

23 current_score: float 

24 delta: float 

25 regression: bool = False 

26 severity: str = "none" # none | minor | moderate | severe 

27 details: str = "" 

28 

29 

30@dataclass 

31class RegressionReport: 

32 """Comparison report between baseline and current evaluation.""" 

33 baseline_label: str = "baseline" 

34 current_label: str = "current" 

35 baseline: EvalReport = None 

36 current: EvalReport = None 

37 checks: List[RegressionCheck] = field(default_factory=list) 

38 total_regressions: int = 0 

39 total_improvements: int = 0 

40 pass_delta: float = 0.0 

41 score_delta: float = 0.0 

42 verdict: str = "OK" # OK | WARN | FAIL 

43 

44 def to_markdown(self) -> str: 

45 lines = [ 

46 f"# Regression Report: {self.baseline_label} → {self.current_label}", 

47 "", 

48 f"**Verdict**: `{self.verdict}`", 

49 f"**Pass Rate**: {self.baseline.pass_rate:.1%} → {self.current.pass_rate:.1%} (Δ={self.pass_delta:+.1%})", 

50 f"**Avg Score**: {self.baseline.avg_score:.1f} → {self.current.avg_score:.1f} (Δ={self.score_delta:+.1f})", 

51 f"**Regressions**: {self.total_regressions} | **Improvements**: {self.total_improvements}", 

52 "", 

53 ] 

54 

55 if self.checks: 

56 lines.append("## Detail") 

57 lines.append("| Case ID | Baseline | Current | Δ | Verdict |") 

58 lines.append("|---------|----------|---------|---|---------|") 

59 for c in self.checks: 

60 icon = "RECRESSION" if c.regression else "IMPROVED" if c.delta > 0 else "SAME" 

61 lines.append( 

62 f"| {c.case_id} | {c.baseline_score:.1f} | {c.current_score:.1f} | " 

63 f"{c.delta:+.1f} | {icon} |" 

64 ) 

65 

66 return "\n".join(lines) 

67 

68 

69class RegressionRunner: 

70 """Detect regressions by comparing baseline and current evaluation runs. 

71 

72 Usage: 

73 runner = RegressionRunner(evaluator, baseline=report) 

74 report = await runner.check(current_report) 

75 # or sync: 

76 report = runner.check_sync(current_report) 

77 """ 

78 

79 def __init__( 

80 self, 

81 evaluator: Evaluator, 

82 baseline: Optional[EvalReport] = None, 

83 threshold: float = 5.0, 

84 severe_threshold: float = 20.0, 

85 ): 

86 self.evaluator = evaluator 

87 self.baseline = baseline 

88 self.threshold = threshold 

89 self.severe_threshold = severe_threshold 

90 

91 async def run_baseline(self) -> EvalReport: 

92 """Run and store the baseline.""" 

93 self.baseline = await self.evaluator.run() 

94 return self.baseline 

95 

96 async def check(self, current: Optional[EvalReport] = None) -> RegressionReport: 

97 """Compare current against baseline. If current not given, run it.""" 

98 if current is None: 

99 current = await self.evaluator.run() 

100 return self._compare(current) 

101 

102 def check_sync(self, current: EvalReport) -> RegressionReport: 

103 """Synchronous version for testing.""" 

104 return self._compare(current) 

105 

106 def _compare(self, current: EvalReport) -> RegressionReport: 

107 

108 if self.baseline is None: 

109 raise ValueError("No baseline set. Call run_baseline() first.") 

110 

111 report = RegressionReport( 

112 baseline=self.baseline, 

113 current=current, 

114 pass_delta=current.pass_rate - self.baseline.pass_rate, 

115 score_delta=current.avg_score - self.baseline.avg_score, 

116 ) 

117 

118 # Build lookup from baseline results 

119 baseline_map: Dict[str, ScoreDetail] = { 

120 r.case_id: r for r in self.baseline.results 

121 } 

122 

123 for current_result in current.results: 

124 cid = current_result.case_id 

125 baseline_result = baseline_map.get(cid) 

126 

127 if baseline_result is None: 

128 # New case, no baseline comparison 

129 report.checks.append(RegressionCheck( 

130 case_id=cid, 

131 baseline_score=0, 

132 current_score=current_result.total_score, 

133 delta=0, 

134 details="new case", 

135 )) 

136 continue 

137 

138 delta = current_result.total_score - baseline_result.total_score 

139 regression = delta < -self.threshold 

140 

141 severity = "none" 

142 if delta < -self.severe_threshold: 

143 severity = "severe" 

144 elif delta < -self.threshold: 

145 severity = "moderate" 

146 elif delta < 0: 

147 severity = "minor" 

148 

149 if regression: 

150 report.total_regressions += 1 

151 elif delta > self.threshold: 

152 report.total_improvements += 1 

153 

154 report.checks.append(RegressionCheck( 

155 case_id=cid, 

156 baseline_score=baseline_result.total_score, 

157 current_score=current_result.total_score, 

158 delta=round(delta, 1), 

159 regression=regression, 

160 severity=severity, 

161 )) 

162 

163 # Verdict 

164 if report.total_regressions > 0: 

165 has_severe = any(c.severity == "severe" for c in report.checks) 

166 report.verdict = "FAIL" if has_severe else "WARN" 

167 

168 return report 

169 

170 

171# --- Statistical Runner --- 

172 

173 

174@dataclass 

175class StatResult: 

176 """Statistical summary of N evaluation runs.""" 

177 trials: int = 0 

178 pass_rates: List[float] = field(default_factory=list) 

179 avg_scores: List[float] = field(default_factory=list) 

180 mean_pass_rate: float = 0.0 

181 std_pass_rate: float = 0.0 

182 mean_score: float = 0.0 

183 std_score: float = 0.0 

184 ci95_pass_rate: Tuple[float, float] = (0.0, 0.0) 

185 ci95_score: Tuple[float, float] = (0.0, 0.0) 

186 

187 def to_dict(self) -> dict: 

188 return { 

189 "trials": self.trials, 

190 "mean_pass_rate": round(self.mean_pass_rate, 4), 

191 "std_pass_rate": round(self.std_pass_rate, 4), 

192 "ci95_pass_rate": [round(x, 4) for x in self.ci95_pass_rate], 

193 "mean_score": round(self.mean_score, 2), 

194 "std_score": round(self.std_score, 2), 

195 "ci95_score": [round(x, 2) for x in self.ci95_score], 

196 } 

197 

198 

199class StatisticalRunner: 

200 """Run evaluation N times and compute statistics. 

201 

202 Usage: 

203 srunner = StatisticalRunner(evaluator, trials=10) 

204 stats = await srunner.run() 

205 """ 

206 

207 def __init__(self, evaluator: Evaluator, trials: int = 5): 

208 self.evaluator = evaluator 

209 self.trials = max(trials, 2) 

210 

211 async def run(self) -> StatResult: 

212 pass_rates = [] 

213 avg_scores = [] 

214 

215 for i in range(self.trials): 

216 report = await self.evaluator.run() 

217 pass_rates.append(report.pass_rate) 

218 avg_scores.append(report.avg_score) 

219 

220 return self._compute(pass_rates, avg_scores) 

221 

222 def _compute(self, pass_rates: List[float], avg_scores: List[float]) -> StatResult: 

223 n = len(pass_rates) 

224 mean_pr = sum(pass_rates) / n 

225 var_pr = sum((x - mean_pr) ** 2 for x in pass_rates) / (n - 1) 

226 std_pr = math.sqrt(var_pr) if var_pr > 0 else 0 

227 

228 mean_s = sum(avg_scores) / n 

229 var_s = sum((x - mean_s) ** 2 for x in avg_scores) / (n - 1) 

230 std_s = math.sqrt(var_s) if var_s > 0 else 0 

231 

232 # 95% CI using t-distribution approximation (z for simplicity) 

233 z = 1.96 

234 ci_pr = ( 

235 max(0, mean_pr - z * std_pr / math.sqrt(n)), 

236 min(1, mean_pr + z * std_pr / math.sqrt(n)), 

237 ) 

238 ci_s = ( 

239 mean_s - z * std_s / math.sqrt(n), 

240 mean_s + z * std_s / math.sqrt(n), 

241 ) 

242 

243 return StatResult( 

244 trials=n, 

245 pass_rates=pass_rates, 

246 avg_scores=avg_scores, 

247 mean_pass_rate=round(mean_pr, 4), 

248 std_pass_rate=round(std_pr, 4), 

249 mean_score=round(mean_s, 2), 

250 std_score=round(std_s, 2), 

251 ci95_pass_rate=(round(ci_pr[0], 4), round(ci_pr[1], 4)), 

252 ci95_score=(round(ci_s[0], 2), round(ci_s[1], 2)), 

253 ) 

254 

255 

256# --- CI Exports --- 

257 

258 

259def to_junit_xml(report: EvalReport, suite_name: str = "AgentOS Eval") -> str: 

260 """Convert evaluation report to JUnit XML for CI integration (GitHub Actions, Jenkins, etc.).""" 

261 total = len(report.results) 

262 failed_count = sum(1 for r in report.results if not r.passed) 

263 total_time = sum(r.duration_ms for r in report.results) / 1000 

264 

265 suite = ET.Element("testsuite", { 

266 "name": suite_name, 

267 "tests": str(total), 

268 "failures": str(failed_count), 

269 "errors": "0", 

270 "skipped": "0", 

271 "time": f"{total_time:.3f}", 

272 "timestamp": report.timestamp or "", 

273 }) 

274 

275 for result in report.results: 

276 testcase = ET.SubElement(suite, "testcase", { 

277 "classname": f"AgentOS.{report.dataset_name}", 

278 "name": result.case_id, 

279 "time": f"{result.duration_ms / 1000:.3f}", 

280 }) 

281 

282 if not result.passed: 

283 failure = ET.SubElement(testcase, "failure", { 

284 "type": "ScoreBelowThreshold", 

285 "message": f"Score {result.total_score:.1f}: {result.details}", 

286 }) 

287 failure.text = f"Actual: {result.actual_output[:500]}\nErrors: {result.errors}" 

288 

289 # Add score as property 

290 props = ET.SubElement(testcase, "properties") 

291 ET.SubElement(props, "property", { 

292 "name": "score", "value": f"{result.total_score:.1f}" 

293 }) 

294 ET.SubElement(props, "property", { 

295 "name": "metrics", "value": json.dumps(result.metrics) 

296 }) 

297 

298 return ET.tostring(suite, encoding="unicode") 

299 

300 

301def to_json(report: EvalReport, indent: int = 2) -> str: 

302 """Serialize EvalReport to JSON string.""" 

303 import dataclasses 

304 

305 class ReportEncoder(json.JSONEncoder): 

306 def default(self, obj): 

307 if dataclasses.is_dataclass(obj): 

308 return dataclasses.asdict(obj) 

309 return super().default(obj) 

310 

311 return json.dumps(report, cls=ReportEncoder, indent=indent, ensure_ascii=False) 

312 

313 

314def save_report(report: EvalReport, path: str, format: str = "markdown"): 

315 """Save evaluation report to file (markdown / json / junit).""" 

316 if format == "markdown": 

317 content = report.to_markdown() 

318 elif format == "json": 

319 content = to_json(report) 

320 elif format == "junit": 

321 content = to_junit_xml(report) 

322 else: 

323 raise ValueError(f"Unknown format: {format}") 

324 

325 with open(path, "w", encoding="utf-8") as f: 

326 f.write(content)