evalrx 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (339) hide show
  1. evalrx/__init__.py +139 -0
  2. evalrx/agent_assets/__init__.py +2 -0
  3. evalrx/agent_assets/skills/README.md +28 -0
  4. evalrx/agent_assets/skills/eval-chart-style/SKILL.md +172 -0
  5. evalrx/agent_assets/skills/evalrx-report-ui/SKILL.md +116 -0
  6. evalrx/agent_assets/skills/nature-figure/LICENSE +201 -0
  7. evalrx/agent_assets/skills/nature-figure/README.md +412 -0
  8. evalrx/agent_assets/skills/nature-figure/SKILL.md +60 -0
  9. evalrx/agent_assets/skills/nature-figure/manifest.yaml +59 -0
  10. evalrx/agent_assets/skills/nature-figure/references/api.md +436 -0
  11. evalrx/agent_assets/skills/nature-figure/references/backend-selection.md +100 -0
  12. evalrx/agent_assets/skills/nature-figure/references/chart-types.md +281 -0
  13. evalrx/agent_assets/skills/nature-figure/references/common-patterns.md +350 -0
  14. evalrx/agent_assets/skills/nature-figure/references/demos.md +65 -0
  15. evalrx/agent_assets/skills/nature-figure/references/design-theory.md +439 -0
  16. evalrx/agent_assets/skills/nature-figure/references/figure-contract.md +93 -0
  17. evalrx/agent_assets/skills/nature-figure/references/figure-legend-conventions.md +71 -0
  18. evalrx/agent_assets/skills/nature-figure/references/nature-2026-observations.md +112 -0
  19. evalrx/agent_assets/skills/nature-figure/references/qa-contract.md +119 -0
  20. evalrx/agent_assets/skills/nature-figure/references/r-template-index.md +66 -0
  21. evalrx/agent_assets/skills/nature-figure/references/r-workflow.md +161 -0
  22. evalrx/agent_assets/skills/nature-figure/references/tutorials.md +251 -0
  23. evalrx/agent_assets/skills/nature-figure/static/core/contract.md +29 -0
  24. evalrx/agent_assets/skills/nature-figure/static/core/stance.md +37 -0
  25. evalrx/agent_assets/skills/nature-figure/static/fragments/backend/python.md +37 -0
  26. evalrx/agent_assets/skills/nature-figure/static/fragments/backend/r.md +44 -0
  27. evalrx/agent_assets/skills/outcome-driver-analysis/SKILL.md +213 -0
  28. evalrx/agent_assets/skills/outcome-driver-analysis/assets/analysis_report_template.md +53 -0
  29. evalrx/agent_assets/skills/outcome-driver-analysis/references/model_selection.md +72 -0
  30. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/explanatory_var_eda.R +130 -0
  31. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/explanatory_var_eda.py +150 -0
  32. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/fit_outcome_model.R +181 -0
  33. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/fit_outcome_model.py +186 -0
  34. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/univariate_eda.R +149 -0
  35. evalrx/agent_assets/skills/outcome-driver-analysis/scripts/univariate_eda.py +177 -0
  36. evalrx/agent_assets/skills.py +27 -0
  37. evalrx/agent_runtime/__init__.py +78 -0
  38. evalrx/agent_runtime/_docker_runner.py +89 -0
  39. evalrx/agent_runtime/cli_runtime.py +103 -0
  40. evalrx/agent_runtime/cli_transcript.py +138 -0
  41. evalrx/agent_runtime/cli_types.py +68 -0
  42. evalrx/agent_runtime/codegen/__init__.py +5 -0
  43. evalrx/agent_runtime/codegen/runner.py +94 -0
  44. evalrx/agent_runtime/experiment_harness.py +117 -0
  45. evalrx/agent_runtime/factory.py +102 -0
  46. evalrx/agent_runtime/json_shape.py +44 -0
  47. evalrx/agent_runtime/judges/__init__.py +28 -0
  48. evalrx/agent_runtime/judges/agy.py +179 -0
  49. evalrx/agent_runtime/judges/autodetect.py +135 -0
  50. evalrx/agent_runtime/judges/claude.py +159 -0
  51. evalrx/agent_runtime/judges/codex.py +120 -0
  52. evalrx/agent_runtime/providers/__init__.py +21 -0
  53. evalrx/agent_runtime/providers/antigravity.py +31 -0
  54. evalrx/agent_runtime/providers/base.py +145 -0
  55. evalrx/agent_runtime/providers/claude_code.py +49 -0
  56. evalrx/agent_runtime/providers/codex.py +37 -0
  57. evalrx/agent_runtime/providers/gemini_cli.py +26 -0
  58. evalrx/agent_runtime/providers/kimi_cli.py +27 -0
  59. evalrx/agent_runtime/providers/opencode.py +27 -0
  60. evalrx/agent_runtime/providers/registry.py +58 -0
  61. evalrx/agent_runtime/sandbox.py +517 -0
  62. evalrx/agent_runtime/skill_audit.py +143 -0
  63. evalrx/agent_runtime/skills/__init__.py +19 -0
  64. evalrx/agent_runtime/skills/installer.py +68 -0
  65. evalrx/agent_runtime/skills/prompt_policy.py +86 -0
  66. evalrx/agent_runtime/skills/resolver.py +19 -0
  67. evalrx/analysis/__init__.py +132 -0
  68. evalrx/analysis/adjudicate.py +154 -0
  69. evalrx/analysis/analysis_module.py +361 -0
  70. evalrx/analysis/api.py +171 -0
  71. evalrx/analysis/case_studio.py +651 -0
  72. evalrx/analysis/cli.py +114 -0
  73. evalrx/analysis/dashboard.py +350 -0
  74. evalrx/analysis/eval_case_matrix.py +118 -0
  75. evalrx/analysis/eval_viz_theme.py +833 -0
  76. evalrx/analysis/explore_run.py +333 -0
  77. evalrx/analysis/explorer.py +1276 -0
  78. evalrx/analysis/failure_modes.py +607 -0
  79. evalrx/analysis/fused_pipeline.py +489 -0
  80. evalrx/analysis/holdout.py +300 -0
  81. evalrx/analysis/hypothesis_agent.py +230 -0
  82. evalrx/analysis/narration.py +177 -0
  83. evalrx/analysis/operationalize.py +442 -0
  84. evalrx/analysis/plain_language.py +42 -0
  85. evalrx/analysis/planner.py +283 -0
  86. evalrx/analysis/probe_search.py +203 -0
  87. evalrx/analysis/profile.py +268 -0
  88. evalrx/analysis/prompts/__init__.py +0 -0
  89. evalrx/analysis/prompts/explorer.py +417 -0
  90. evalrx/analysis/prompts/failure_modes.py +33 -0
  91. evalrx/analysis/prompts/holdout.py +27 -0
  92. evalrx/analysis/prompts/hypothesis_agent.py +78 -0
  93. evalrx/analysis/prompts/run_codebase.py +47 -0
  94. evalrx/analysis/prompts/stats_agent.py +72 -0
  95. evalrx/analysis/prompts/stats_tool_generator.py +43 -0
  96. evalrx/analysis/result_marker.py +47 -0
  97. evalrx/analysis/run_codebase.py +242 -0
  98. evalrx/analysis/run_view.py +205 -0
  99. evalrx/analysis/stage_views.py +93 -0
  100. evalrx/analysis/stats_agent.py +944 -0
  101. evalrx/analysis/stats_tool_agent.py +261 -0
  102. evalrx/analysis/stats_tool_generator.py +415 -0
  103. evalrx/analysis/stats_tools.py +1153 -0
  104. evalrx/analysis/trajectory_records.py +193 -0
  105. evalrx/analysis/workbench.py +431 -0
  106. evalrx/analyzers/__init__.py +42 -0
  107. evalrx/analyzers/agent/__init__.py +25 -0
  108. evalrx/analyzers/agent/counterfactual.py +84 -0
  109. evalrx/analyzers/agent/first_error_judge.py +96 -0
  110. evalrx/analyzers/agent/ignored_obs.py +81 -0
  111. evalrx/analyzers/agent/loop_detect.py +79 -0
  112. evalrx/analyzers/agent/reliability.py +165 -0
  113. evalrx/analyzers/agent/tool_shap.py +225 -0
  114. evalrx/analyzers/agent/trajectory_rubric.py +168 -0
  115. evalrx/analyzers/attention/__init__.py +19 -0
  116. evalrx/analyzers/attention/relative_attn.py +610 -0
  117. evalrx/analyzers/attention/rollout.py +73 -0
  118. evalrx/analyzers/attention/sink.py +56 -0
  119. evalrx/analyzers/attention/summary.py +190 -0
  120. evalrx/analyzers/attribution/__init__.py +6 -0
  121. evalrx/analyzers/attribution/generic_attn.py +31 -0
  122. evalrx/analyzers/attribution/gradcam.py +30 -0
  123. evalrx/analyzers/base.py +12 -0
  124. evalrx/analyzers/geometry/__init__.py +6 -0
  125. evalrx/analyzers/geometry/cka.py +70 -0
  126. evalrx/analyzers/geometry/linear_probe.py +157 -0
  127. evalrx/analyzers/hallucination/__init__.py +9 -0
  128. evalrx/analyzers/hallucination/chair.py +78 -0
  129. evalrx/analyzers/hallucination/opera.py +29 -0
  130. evalrx/analyzers/hallucination/pope.py +119 -0
  131. evalrx/analyzers/hallucination/selfcheck.py +155 -0
  132. evalrx/analyzers/hallucination/vcd.py +29 -0
  133. evalrx/analyzers/lens/__init__.py +7 -0
  134. evalrx/analyzers/lens/layer_contrast.py +133 -0
  135. evalrx/analyzers/lens/logit_lens.py +138 -0
  136. evalrx/analyzers/lens/tuned_lens.py +30 -0
  137. evalrx/analyzers/patching/__init__.py +5 -0
  138. evalrx/analyzers/patching/causal_trace.py +30 -0
  139. evalrx/analyzers/perturbation/__init__.py +23 -0
  140. evalrx/analyzers/perturbation/_shapley.py +54 -0
  141. evalrx/analyzers/perturbation/context_shap.py +174 -0
  142. evalrx/analyzers/perturbation/cot_faithfulness.py +239 -0
  143. evalrx/analyzers/perturbation/format_sensitivity.py +237 -0
  144. evalrx/analyzers/perturbation/mm_shap.py +146 -0
  145. evalrx/analyzers/perturbation/modality_ablation.py +196 -0
  146. evalrx/analyzers/perturbation/perturbation_battery.py +274 -0
  147. evalrx/analyzers/perturbation/prompt_contrast.py +265 -0
  148. evalrx/analyzers/perturbation/rise.py +94 -0
  149. evalrx/analyzers/perturbation/vl_shap.py +102 -0
  150. evalrx/analyzers/reasoning/__init__.py +33 -0
  151. evalrx/analyzers/reasoning/_text.py +328 -0
  152. evalrx/analyzers/reasoning/answer_extraction_audit.py +327 -0
  153. evalrx/analyzers/reasoning/arith_audit.py +226 -0
  154. evalrx/analyzers/reasoning/contamination.py +214 -0
  155. evalrx/analyzers/reasoning/knowledge_split.py +253 -0
  156. evalrx/analyzers/reasoning/self_repair.py +246 -0
  157. evalrx/analyzers/reasoning/step_rollout_value.py +216 -0
  158. evalrx/analyzers/reasoning/termination_audit.py +258 -0
  159. evalrx/analyzers/uncertainty/__init__.py +18 -0
  160. evalrx/analyzers/uncertainty/calibration.py +174 -0
  161. evalrx/analyzers/uncertainty/coverage_gap.py +199 -0
  162. evalrx/analyzers/uncertainty/entropy.py +90 -0
  163. evalrx/analyzers/uncertainty/logprob_entropy.py +69 -0
  164. evalrx/analyzers/uncertainty/self_consistency.py +204 -0
  165. evalrx/analyzers/uncertainty/verbalized_conf.py +64 -0
  166. evalrx/cli.py +411 -0
  167. evalrx/config.py +77 -0
  168. evalrx/contract/__init__.py +179 -0
  169. evalrx/contract/common.py +452 -0
  170. evalrx/contract/emit.py +948 -0
  171. evalrx/contract/export.py +237 -0
  172. evalrx/contract/m1.py +325 -0
  173. evalrx/contract/m2.py +317 -0
  174. evalrx/contract/m3.py +165 -0
  175. evalrx/contract/m4.py +130 -0
  176. evalrx/contract/m5.py +292 -0
  177. evalrx/contract/methodology.py +76 -0
  178. evalrx/contract/pre_m1.py +58 -0
  179. evalrx/contract/typescript.py +140 -0
  180. evalrx/core/__init__.py +85 -0
  181. evalrx/core/analyzer.py +174 -0
  182. evalrx/core/capability.py +54 -0
  183. evalrx/core/case.py +443 -0
  184. evalrx/core/experiment.py +106 -0
  185. evalrx/core/model.py +198 -0
  186. evalrx/core/pipeline.py +42 -0
  187. evalrx/core/registry.py +142 -0
  188. evalrx/core/result.py +64 -0
  189. evalrx/core/spec.py +173 -0
  190. evalrx/core/tokentype.py +165 -0
  191. evalrx/core/tool.py +92 -0
  192. evalrx/datasets/__init__.py +41 -0
  193. evalrx/datasets/base.py +68 -0
  194. evalrx/datasets/gui_os.py +52 -0
  195. evalrx/datasets/llm_qa.py +57 -0
  196. evalrx/datasets/pure_qa.py +12 -0
  197. evalrx/datasets/vlm_qa.py +695 -0
  198. evalrx/datasets/web_search_qa.py +52 -0
  199. evalrx/eval_agent/__init__.py +341 -0
  200. evalrx/eval_agent/_tools.py +81 -0
  201. evalrx/eval_agent/ab_runner.py +50 -0
  202. evalrx/eval_agent/agentic/__init__.py +43 -0
  203. evalrx/eval_agent/agentic/actions.py +216 -0
  204. evalrx/eval_agent/agentic/board.py +107 -0
  205. evalrx/eval_agent/agentic/loop.py +190 -0
  206. evalrx/eval_agent/agentic/tools.py +538 -0
  207. evalrx/eval_agent/checkpoint.py +57 -0
  208. evalrx/eval_agent/cli_agent.py +59 -0
  209. evalrx/eval_agent/cli_skills.py +5 -0
  210. evalrx/eval_agent/evolution.py +396 -0
  211. evalrx/eval_agent/git_manager.py +215 -0
  212. evalrx/eval_agent/hypothesis.py +172 -0
  213. evalrx/eval_agent/label_quarantine.py +209 -0
  214. evalrx/eval_agent/legacy.py +530 -0
  215. evalrx/eval_agent/log_schema.py +497 -0
  216. evalrx/eval_agent/loop.py +2159 -0
  217. evalrx/eval_agent/loop_reports.py +116 -0
  218. evalrx/eval_agent/model_instrumentation.py +282 -0
  219. evalrx/eval_agent/narration.py +193 -0
  220. evalrx/eval_agent/nl_runner.py +460 -0
  221. evalrx/eval_agent/orchestrator.py +61 -0
  222. evalrx/eval_agent/preregister.py +93 -0
  223. evalrx/eval_agent/prompts/__init__.py +1 -0
  224. evalrx/eval_agent/prompts/agentic.py +46 -0
  225. evalrx/eval_agent/prompts/case_discovery.py +25 -0
  226. evalrx/eval_agent/prompts/diagnosis.py +125 -0
  227. evalrx/eval_agent/prompts/experiment_writer.py +265 -0
  228. evalrx/eval_agent/prompts/explore_step.py +37 -0
  229. evalrx/eval_agent/prompts/fix_agent.py +257 -0
  230. evalrx/eval_agent/prompts/hypothesis_tester.py +15 -0
  231. evalrx/eval_agent/prompts/nl_runner.py +38 -0
  232. evalrx/eval_agent/prompts/probe_agent.py +25 -0
  233. evalrx/eval_agent/prompts/probe_candidate_generator.py +14 -0
  234. evalrx/eval_agent/prompts/probe_generator.py +35 -0
  235. evalrx/eval_agent/prompts/whitebox_probe_generator.py +38 -0
  236. evalrx/eval_agent/report.py +58 -0
  237. evalrx/eval_agent/run_context.py +354 -0
  238. evalrx/eval_agent/run_log.schema.json +1215 -0
  239. evalrx/eval_agent/run_logger_v2.py +1764 -0
  240. evalrx/eval_agent/run_metadata.py +208 -0
  241. evalrx/eval_agent/stages/__init__.py +56 -0
  242. evalrx/eval_agent/stages/case_discovery.py +293 -0
  243. evalrx/eval_agent/stages/diagnosis.py +1017 -0
  244. evalrx/eval_agent/stages/experiment_writer.py +1634 -0
  245. evalrx/eval_agent/stages/fix_agent.py +3916 -0
  246. evalrx/eval_agent/stages/fix_internals.py +499 -0
  247. evalrx/eval_agent/stages/fix_pipeline.py +725 -0
  248. evalrx/eval_agent/stages/fix_tiers.py +187 -0
  249. evalrx/eval_agent/stages/fix_tools.py +1034 -0
  250. evalrx/eval_agent/stages/hypothesis_tester.py +1014 -0
  251. evalrx/eval_agent/stages/probe.py +439 -0
  252. evalrx/eval_agent/stages/probe_agent.py +1079 -0
  253. evalrx/eval_agent/stages/probe_candidate_generator.py +128 -0
  254. evalrx/eval_agent/stages/probe_generator.py +326 -0
  255. evalrx/eval_agent/stages/probe_search_agent.py +106 -0
  256. evalrx/eval_agent/stages/protocol.py +112 -0
  257. evalrx/eval_agent/stages/repair_catalog.py +273 -0
  258. evalrx/eval_agent/stages/surgery.py +524 -0
  259. evalrx/eval_agent/stages/whitebox_probe_generator.py +351 -0
  260. evalrx/eval_agent/store.py +231 -0
  261. evalrx/logging_utils.py +112 -0
  262. evalrx/models/__init__.py +161 -0
  263. evalrx/models/_discover.py +101 -0
  264. evalrx/models/agent.py +380 -0
  265. evalrx/models/backends/__init__.py +58 -0
  266. evalrx/models/backends/api.py +169 -0
  267. evalrx/models/backends/base.py +57 -0
  268. evalrx/models/backends/gemini_compat.py +579 -0
  269. evalrx/models/backends/hf_local.py +2074 -0
  270. evalrx/models/backends/openai_compat.py +301 -0
  271. evalrx/models/backends/vllm_offline.py +116 -0
  272. evalrx/models/base.py +24 -0
  273. evalrx/models/blackbox/__init__.py +4 -0
  274. evalrx/models/blackbox/agent.py +31 -0
  275. evalrx/models/blackbox/base.py +29 -0
  276. evalrx/models/blackbox/gemini.py +279 -0
  277. evalrx/models/blackbox/llm_api.py +17 -0
  278. evalrx/models/blackbox/vlm_api.py +17 -0
  279. evalrx/models/compose.py +66 -0
  280. evalrx/models/inference.py +88 -0
  281. evalrx/models/paper_methods/__init__.py +8 -0
  282. evalrx/models/paper_methods/aad.py +53 -0
  283. evalrx/models/paper_methods/ifcd.py +204 -0
  284. evalrx/models/paper_methods/pai.py +164 -0
  285. evalrx/models/paper_methods/tcd.py +202 -0
  286. evalrx/models/paper_methods/vcd.py +45 -0
  287. evalrx/models/paper_methods/vicrop.py +137 -0
  288. evalrx/models/toolcodec.py +143 -0
  289. evalrx/models/tools/__init__.py +20 -0
  290. evalrx/models/tools/perception.py +300 -0
  291. evalrx/models/tools/visual.py +174 -0
  292. evalrx/models/whitebox/__init__.py +26 -0
  293. evalrx/models/whitebox/agent.py +31 -0
  294. evalrx/models/whitebox/base.py +24 -0
  295. evalrx/models/whitebox/qwen.py +61 -0
  296. evalrx/models/whitebox/qwen2_5_omni.py +29 -0
  297. evalrx/models/whitebox/qwen2_audio.py +25 -0
  298. evalrx/models/whitebox/qwen_omni.py +53 -0
  299. evalrx/models/whitebox/qwen_vl.py +62 -0
  300. evalrx/observability/__init__.py +21 -0
  301. evalrx/observability/envelope.py +122 -0
  302. evalrx/observability/outbox.py +111 -0
  303. evalrx/observability/tracer.py +882 -0
  304. evalrx/reporting/__init__.py +28 -0
  305. evalrx/reporting/case_study.py +947 -0
  306. evalrx/reporting/compiler.py +587 -0
  307. evalrx/reporting/dynamic.py +1882 -0
  308. evalrx/reporting/html_report.py +2225 -0
  309. evalrx/reporting/langfuse_exporter.py +38 -0
  310. evalrx/reporting/langfuse_source.py +155 -0
  311. evalrx/reporting/model.py +151 -0
  312. evalrx/reporting/run_events.py +184 -0
  313. evalrx/reporting/server.py +557 -0
  314. evalrx/reporting/stages.py +58 -0
  315. evalrx/reporting/static_export.py +142 -0
  316. evalrx/reporting/web_dist/index.html +146 -0
  317. evalrx/specs.py +727 -0
  318. evalrx/stats/__init__.py +47 -0
  319. evalrx/stats/api.py +192 -0
  320. evalrx/stats/bootstrap.py +86 -0
  321. evalrx/stats/ebh.py +27 -0
  322. evalrx/stats/evalue.py +98 -0
  323. evalrx/stats/friedman.py +138 -0
  324. evalrx/stats/mcnemar.py +40 -0
  325. evalrx/stats/multiplicity.py +159 -0
  326. evalrx/stats/subset_sampling.py +55 -0
  327. evalrx/term_links.py +43 -0
  328. evalrx/viz/__init__.py +7 -0
  329. evalrx/viz/labels.py +77 -0
  330. evalrx/viz/prompts.py +39 -0
  331. evalrx/viz/renderer.py +590 -0
  332. evalrx/viz/schema.py +36 -0
  333. evalrx/viz/style.py +134 -0
  334. evalrx-0.1.2.dist-info/METADATA +532 -0
  335. evalrx-0.1.2.dist-info/RECORD +339 -0
  336. evalrx-0.1.2.dist-info/WHEEL +5 -0
  337. evalrx-0.1.2.dist-info/entry_points.txt +3 -0
  338. evalrx-0.1.2.dist-info/licenses/LICENSE +121 -0
  339. evalrx-0.1.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,396 @@
1
+ """EvolutionStore — cross-run lesson accumulation with time-decay weighting.
2
+
3
+ Mirrors ``researchclaw/evolution.py`` adapted to the evalrx M1→M5 pipeline.
4
+
5
+ The store is JSONL-backed (append-only) so it accumulates lessons across many
6
+ diagnosis runs. When building prompt overlays for the LLM agents, lessons are
7
+ weighted by a 30-day half-life exponential decay — older insights matter less.
8
+
9
+ Usage::
10
+
11
+ store = EvolutionStore(run_dir / "evolution")
12
+ lessons = extract_lessons(report)
13
+ store.append_many(lessons)
14
+
15
+ # Inject relevant context into M3 DiagnosisAgent prompt
16
+ overlay = store.build_overlay("diagnosis", max_lessons=5)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import logging
23
+ import math
24
+ import re as _re
25
+ from dataclasses import asdict, dataclass
26
+ from datetime import datetime, timezone
27
+ from pathlib import Path
28
+ from typing import TYPE_CHECKING
29
+
30
+ if TYPE_CHECKING:
31
+ from evalrx.eval_agent.loop_reports import AutoDiagnoseReport
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Keyword tokenisation for relevance scoring
37
+ # ---------------------------------------------------------------------------
38
+
39
+ _TOKEN_RE = _re.compile(r"[a-z0-9]+")
40
+
41
+
42
+ def _tokenise(text: str) -> frozenset[str]:
43
+ """Lowercase + split on non-alphanumeric characters; return a frozenset of tokens."""
44
+ return frozenset(_TOKEN_RE.findall(text.lower()))
45
+
46
+ # Half-life for time-decay weighting (days)
47
+ HALF_LIFE_DAYS: float = 30.0
48
+ MAX_AGE_DAYS: float = 90.0
49
+
50
+ # Valid category tags matching the M1–M5 pipeline stages
51
+ CATEGORIES = frozenset({"probe", "analysis", "diagnosis", "surgery"})
52
+ SEVERITIES = frozenset({"info", "warning", "error"})
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Data model
57
+ # ---------------------------------------------------------------------------
58
+
59
+
60
+ @dataclass
61
+ class LessonEntry:
62
+ """A single lesson extracted from a diagnosis run.
63
+
64
+ Attributes:
65
+ run_id: Identifier of the run that generated this lesson.
66
+ cycle: M1→M5 cycle number within that run.
67
+ category: Pipeline stage: ``"probe" | "analysis" | "diagnosis" | "surgery"``.
68
+ severity: ``"info" | "warning" | "error"``.
69
+ description: Human-readable lesson text (injected into prompts).
70
+ timestamp: ISO 8601 UTC timestamp.
71
+ """
72
+
73
+ run_id: str
74
+ cycle: int
75
+ category: str
76
+ severity: str
77
+ description: str
78
+ timestamp: str
79
+
80
+ def to_dict(self) -> dict[str, object]:
81
+ return asdict(self)
82
+
83
+ @classmethod
84
+ def from_dict(cls, data: dict[str, object]) -> LessonEntry:
85
+ return cls(
86
+ run_id=str(data.get("run_id", "")),
87
+ cycle=int(data.get("cycle", 0)),
88
+ category=str(data.get("category", "surgery")),
89
+ severity=str(data.get("severity", "info")),
90
+ description=str(data.get("description", "")),
91
+ timestamp=str(data.get("timestamp", _utcnow_iso())),
92
+ )
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Time-decay weighting
97
+ # ---------------------------------------------------------------------------
98
+
99
+
100
+ def _utcnow_iso() -> str:
101
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
102
+
103
+
104
+ def _time_weight(timestamp_iso: str) -> float:
105
+ """30-day half-life exponential decay; returns 0.0 after 90 days."""
106
+ try:
107
+ ts = datetime.fromisoformat(timestamp_iso)
108
+ if ts.tzinfo is None:
109
+ ts = ts.replace(tzinfo=timezone.utc)
110
+ age_days = (datetime.now(timezone.utc) - ts).total_seconds() / 86400.0
111
+ except (ValueError, TypeError):
112
+ return 0.5 # unknown age — treat as moderate recency
113
+
114
+ if age_days > MAX_AGE_DAYS:
115
+ return 0.0
116
+ return math.exp(-age_days * math.log(2) / HALF_LIFE_DAYS)
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # Lesson extraction
121
+ # ---------------------------------------------------------------------------
122
+
123
+
124
+ def extract_lessons(report: "AutoDiagnoseReport") -> list[LessonEntry]:
125
+ """Auto-extract lessons from a completed :class:`AutoDiagnoseReport`.
126
+
127
+ Detects:
128
+ - INCONCLUSIVE hypotheses → surgery/warning
129
+ - Loop unresolved after exhausting max_cycles → diagnosis/warning
130
+ - HIGH or CRITICAL analysis severity with no resolution → analysis/info
131
+ """
132
+ from evalrx.eval_agent.hypothesis import HypothesisStatus
133
+
134
+ lessons: list[LessonEntry] = []
135
+ run_id = getattr(report, "_run_id", "unknown")
136
+ cycles = getattr(report, "cycles", 0)
137
+ ts = _utcnow_iso()
138
+
139
+ # INCONCLUSIVE hypotheses
140
+ for h in getattr(report, "final_hypotheses", []):
141
+ if getattr(h, "status", None) == HypothesisStatus.INCONCLUSIVE:
142
+ lessons.append(LessonEntry(
143
+ run_id=run_id,
144
+ cycle=cycles,
145
+ category="surgery",
146
+ severity="warning",
147
+ description=(
148
+ f"Hypothesis INCONCLUSIVE: '{h.statement[:120]}' "
149
+ f"(failure_mode={h.predicted_failure_mode}). "
150
+ "Consider a more targeted experiment or more failure cases."
151
+ ),
152
+ timestamp=ts,
153
+ ))
154
+
155
+ # Loop exhausted without resolution
156
+ if not getattr(report, "resolved", False):
157
+ lessons.append(LessonEntry(
158
+ run_id=run_id,
159
+ cycle=cycles,
160
+ category="diagnosis",
161
+ severity="warning",
162
+ description=(
163
+ f"Loop exhausted {cycles} cycle(s) without resolving the failure. "
164
+ "Hypotheses may need broader scope or additional analyzer coverage."
165
+ ),
166
+ timestamp=ts,
167
+ ))
168
+
169
+ # High-severity analysis finding without resolution
170
+ analysis = getattr(report, "final_analysis", None)
171
+ if analysis is not None:
172
+ severity = getattr(analysis, "severity", "")
173
+ if severity in ("HIGH", "CRITICAL") and not getattr(report, "resolved", False):
174
+ lessons.append(LessonEntry(
175
+ run_id=run_id,
176
+ cycle=cycles,
177
+ category="analysis",
178
+ severity="info",
179
+ description=(
180
+ f"Analysis severity={severity} but no hypothesis was confirmed. "
181
+ "The analyzer findings may point to a failure mode not yet hypothesised."
182
+ ),
183
+ timestamp=ts,
184
+ ))
185
+
186
+ return lessons
187
+
188
+
189
+ # ---------------------------------------------------------------------------
190
+ # EvolutionStore
191
+ # ---------------------------------------------------------------------------
192
+
193
+
194
+ class EvolutionStore:
195
+ """JSONL-backed store for diagnosis lessons with 30-day half-life time decay.
196
+
197
+ Args:
198
+ store_dir: Directory where ``lessons.jsonl`` is written.
199
+ """
200
+
201
+ def __init__(self, store_dir: Path | str) -> None:
202
+ self._dir = Path(store_dir)
203
+ self._dir.mkdir(parents=True, exist_ok=True)
204
+ self._lessons_path = self._dir / "lessons.jsonl"
205
+
206
+ @property
207
+ def lessons_path(self) -> Path:
208
+ return self._lessons_path
209
+
210
+ # ------------------------------------------------------------------
211
+ # Write
212
+ # ------------------------------------------------------------------
213
+
214
+ def append(self, lesson: LessonEntry) -> None:
215
+ """Append a single lesson to the store."""
216
+ try:
217
+ with self._lessons_path.open("a", encoding="utf-8") as f:
218
+ f.write(json.dumps(lesson.to_dict(), default=str) + "\n")
219
+ except OSError as exc:
220
+ logger.warning("EvolutionStore: failed to append lesson: %s", exc)
221
+
222
+ def append_many(self, lessons: list[LessonEntry]) -> None:
223
+ """Append multiple lessons in one write."""
224
+ if not lessons:
225
+ return
226
+ try:
227
+ with self._lessons_path.open("a", encoding="utf-8") as f:
228
+ for lesson in lessons:
229
+ f.write(json.dumps(lesson.to_dict(), default=str) + "\n")
230
+ except OSError as exc:
231
+ logger.warning("EvolutionStore: failed to append lessons: %s", exc)
232
+
233
+ # ------------------------------------------------------------------
234
+ # Read
235
+ # ------------------------------------------------------------------
236
+
237
+ def load_all(self) -> list[LessonEntry]:
238
+ """Load all lessons from disk."""
239
+ if not self._lessons_path.exists():
240
+ return []
241
+ entries: list[LessonEntry] = []
242
+ try:
243
+ with self._lessons_path.open(encoding="utf-8") as f:
244
+ for line in f:
245
+ line = line.strip()
246
+ if not line:
247
+ continue
248
+ try:
249
+ entries.append(LessonEntry.from_dict(json.loads(line)))
250
+ except (json.JSONDecodeError, KeyError):
251
+ logger.debug("EvolutionStore: skipping malformed line")
252
+ except OSError as exc:
253
+ logger.warning("EvolutionStore: failed to read lessons: %s", exc)
254
+ return entries
255
+
256
+ def query_for_category(
257
+ self,
258
+ category: str,
259
+ *,
260
+ max_lessons: int = 5,
261
+ context_keywords: list[str] | None = None,
262
+ ) -> list[LessonEntry]:
263
+ """Return the most relevant lessons for *category*.
264
+
265
+ Scoring combines three signals so that a highly relevant older lesson
266
+ can rank above a recent but unrelated one:
267
+
268
+ 1. **Recency** — 30-day exponential half-life.
269
+ 2. **Keyword relevance** — Jaccard-like token overlap between the lesson
270
+ text and the query context supplied via *context_keywords* (e.g. the
271
+ current failure modes, analysis narrative snippets).
272
+ 3. **Severity boost** — errors outrank warnings which outrank info.
273
+
274
+ *context_keywords*: additional terms from the current run context.
275
+ The query always includes the category name itself as a token.
276
+ """
277
+ all_lessons = self.load_all()
278
+ relevant = [lesson for lesson in all_lessons if lesson.category == category]
279
+ if not relevant:
280
+ return []
281
+
282
+ # Build query token set from category + caller-supplied keywords
283
+ query_tokens = _tokenise(category)
284
+ for kw in (context_keywords or []):
285
+ query_tokens |= _tokenise(kw)
286
+
287
+ scored: list[tuple[float, LessonEntry]] = []
288
+ for lesson in relevant:
289
+ weight = _time_weight(lesson.timestamp)
290
+ if weight <= 0.0:
291
+ continue
292
+
293
+ # Keyword relevance: overlap between query and lesson text
294
+ lesson_tokens = _tokenise(lesson.description)
295
+ overlap = len(query_tokens & lesson_tokens)
296
+ if overlap > 0:
297
+ # Cap boost at 3× so recency still matters
298
+ weight *= 1.0 + min(overlap * 0.4, 2.0)
299
+
300
+ # Severity boost
301
+ if lesson.severity == "error":
302
+ weight *= 1.5
303
+ elif lesson.severity == "warning":
304
+ weight *= 1.2
305
+
306
+ scored.append((weight, lesson))
307
+
308
+ scored.sort(key=lambda x: x[0], reverse=True)
309
+ return [entry for _, entry in scored[:max_lessons]]
310
+
311
+ def build_overlay(
312
+ self,
313
+ category: str,
314
+ *,
315
+ max_lessons: int = 5,
316
+ context_keywords: list[str] | None = None,
317
+ caller_run_id: str = "",
318
+ ) -> str:
319
+ """Generate a prompt overlay string for a given M1–M5 *category*.
320
+
321
+ Returns an empty string when no relevant lessons qualify.
322
+ The overlay is formatted for direct injection into LLM prompts.
323
+
324
+ Side-effect: each call is appended to ``injection_log.jsonl`` so
325
+ there is an immutable audit trail of which lessons were injected
326
+ into which runs. This makes it possible to bisect a regression
327
+ back to the specific lesson that changed agent behaviour.
328
+
329
+ Args:
330
+ context_keywords: Extra terms from the current run used to boost
331
+ relevance scoring (failure mode tags, narrative snippets, etc.).
332
+ caller_run_id: Identifier of the run requesting the overlay —
333
+ used to link the injection record back to the loop run.
334
+ """
335
+ lessons = self.query_for_category(
336
+ category, max_lessons=max_lessons, context_keywords=context_keywords
337
+ )
338
+
339
+ self._record_injection(category, lessons, caller_run_id)
340
+
341
+ if not lessons:
342
+ return ""
343
+
344
+ lines = ["## Lessons from Prior Diagnosis Runs"]
345
+ for i, lesson in enumerate(lessons, 1):
346
+ icon = {"error": "[ERROR]", "warning": "[WARN]", "info": "[INFO]"}.get(
347
+ lesson.severity, "[INFO]"
348
+ )
349
+ lines.append(f"{i}. {icon} {lesson.description}")
350
+ return "\n".join(lines)
351
+
352
+ def _record_injection(
353
+ self,
354
+ category: str,
355
+ lessons: list[LessonEntry],
356
+ caller_run_id: str,
357
+ ) -> None:
358
+ """Append one record to ``injection_log.jsonl`` (non-blocking).
359
+
360
+ Each record captures which lessons were injected, when, into which
361
+ run, and for which category. The log is append-only so it acts as
362
+ a provenance chain — a given mutation in agent behaviour can be
363
+ traced back to the exact lesson that was active at the time.
364
+ """
365
+ log_path = self._dir / "injection_log.jsonl"
366
+ record = {
367
+ "ts": _utcnow_iso(),
368
+ "caller_run_id": caller_run_id,
369
+ "category": category,
370
+ "n_lessons": len(lessons),
371
+ "lessons": [
372
+ {
373
+ "run_id": lesson.run_id,
374
+ "cycle": lesson.cycle,
375
+ "severity": lesson.severity,
376
+ "description": lesson.description[:120],
377
+ "timestamp": lesson.timestamp,
378
+ }
379
+ for lesson in lessons
380
+ ],
381
+ }
382
+ try:
383
+ with log_path.open("a", encoding="utf-8") as f:
384
+ f.write(json.dumps(record, default=str) + "\n")
385
+ except OSError as exc:
386
+ logger.debug("EvolutionStore: could not write injection log: %s", exc)
387
+
388
+ def count(self) -> int:
389
+ """Return total number of lessons stored."""
390
+ if not self._lessons_path.exists():
391
+ return 0
392
+ try:
393
+ with self._lessons_path.open(encoding="utf-8") as f:
394
+ return sum(1 for line in f if line.strip())
395
+ except OSError:
396
+ return 0
@@ -0,0 +1,215 @@
1
+ """Git-native experiment version management for the eval_agent loop.
2
+
3
+ Every successful diagnosis run is committed on a branch ``eval/{run_id}``;
4
+ failed or unresolved runs are reset with ``git reset --hard``. This enables
5
+ ``git log`` as a diagnosis journal and easy rollback to a prior state.
6
+
7
+ Ported from ``researchclaw/experiment/git_manager.py`` with adaptations:
8
+ - Branch naming: ``eval/{run_id}`` (was ``experiment/{tag}``)
9
+ - Commit format: ``eval({run_id}) cycle {N}: {desc}\\n\\nHypotheses: {json}``
10
+ - History grep: ``eval(`` (was ``experiment(``)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import logging
17
+ import re
18
+ import subprocess
19
+ from pathlib import Path
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class ExperimentGitManager:
25
+ """Git-native versioning for eval_agent diagnosis runs.
26
+
27
+ Every successful run is a commit on ``eval/{run_id}``. Failed / unresolved
28
+ runs are discarded with ``git reset --hard HEAD``. Git log becomes the
29
+ experiment journal; ``git checkout eval/{run_id}`` replays any prior run.
30
+ """
31
+
32
+ def __init__(self, repo_dir: Path) -> None:
33
+ self.repo_dir: Path = repo_dir
34
+ self._active_branch: str | None = None
35
+ self._original_branch: str | None = self._detect_current_branch()
36
+
37
+ # ------------------------------------------------------------------
38
+ # Public API
39
+ # ------------------------------------------------------------------
40
+
41
+ def is_git_repo(self) -> bool:
42
+ """Return True if *repo_dir* is inside a git repository."""
43
+ result = self._run_git(["rev-parse", "--is-inside-work-tree"])
44
+ return result is not None and result.returncode == 0
45
+
46
+ def get_current_branch(self) -> str:
47
+ """Return the active branch name, or '' if detection fails."""
48
+ return self._detect_current_branch() or ""
49
+
50
+ def create_experiment_branch(self, run_id: str) -> str:
51
+ """Create and check out branch ``eval/{run_id}``.
52
+
53
+ Returns the branch name on success, or '' on failure.
54
+ """
55
+ branch = f"eval/{run_id}"
56
+ result = self._run_git(["checkout", "-b", branch])
57
+ if result is None or result.returncode != 0:
58
+ self._log_git_failure("create_experiment_branch", result)
59
+ return ""
60
+ self._active_branch = branch
61
+ return branch
62
+
63
+ def commit_experiment(
64
+ self,
65
+ run_id: str,
66
+ cycle: int,
67
+ hypothesis_statuses: dict[str, str],
68
+ description: str = "",
69
+ ) -> str:
70
+ """Stage all changes and commit.
71
+
72
+ Returns the commit hash on success, or '' on failure.
73
+ """
74
+ add_result = self._run_git(["add", "-A"])
75
+ if add_result is None or add_result.returncode != 0:
76
+ self._log_git_failure("git add", add_result)
77
+ return ""
78
+
79
+ message = self._format_commit_message(
80
+ run_id=run_id,
81
+ cycle=cycle,
82
+ hypothesis_statuses=hypothesis_statuses,
83
+ description=description,
84
+ )
85
+ commit_result = self._run_git(["commit", "-m", message])
86
+ if commit_result is None or commit_result.returncode != 0:
87
+ self._log_git_failure("git commit", commit_result)
88
+ return ""
89
+
90
+ hash_result = self._run_git(["rev-parse", "HEAD"])
91
+ if hash_result is None or hash_result.returncode != 0:
92
+ self._log_git_failure("git rev-parse HEAD", hash_result)
93
+ return ""
94
+ return self._clean_output(hash_result.stdout)
95
+
96
+ def discard_experiment(self, run_id: str, reason: str) -> bool:
97
+ """Reset working tree to HEAD (discard uncommitted changes).
98
+
99
+ Returns True on success.
100
+ """
101
+ logger.info("Discarding eval run %s: %s", run_id, reason)
102
+ result = self._run_git(["reset", "--hard", "HEAD"])
103
+ if result is None or result.returncode != 0:
104
+ self._log_git_failure("discard_experiment", result)
105
+ return False
106
+ return True
107
+
108
+ def get_experiment_history(self) -> list[dict[str, str]]:
109
+ """Return a list of prior eval commits parsed from git log."""
110
+ result = self._run_git(
111
+ ["log", "--oneline", "--fixed-strings", "--grep", "eval("]
112
+ )
113
+ if result is None or result.returncode != 0:
114
+ self._log_git_failure("git log", result)
115
+ return []
116
+
117
+ history: list[dict[str, str]] = []
118
+ for line in result.stdout.splitlines():
119
+ parsed = self._parse_experiment_log_line(line)
120
+ if parsed is not None:
121
+ history.append(parsed)
122
+ return history
123
+
124
+ def return_to_original_branch(self) -> bool:
125
+ """Switch back to the branch that was active at init time."""
126
+ if not self._original_branch:
127
+ return False
128
+ result = self._run_git(["checkout", self._original_branch])
129
+ if result is None or result.returncode != 0:
130
+ self._log_git_failure("return_to_original_branch", result)
131
+ return False
132
+ self._active_branch = self._original_branch
133
+ return True
134
+
135
+ def get_experiment_diff(self) -> str:
136
+ """Return ``git diff --stat`` of uncommitted changes (for logging)."""
137
+ result = self._run_git(["diff", "--stat"])
138
+ if result is None or result.returncode != 0:
139
+ return ""
140
+ return result.stdout.strip()
141
+
142
+ def clean_untracked(self) -> bool:
143
+ """Remove untracked files and directories (``git clean -fd``)."""
144
+ result = self._run_git(["clean", "-fd"])
145
+ return result is not None and result.returncode == 0
146
+
147
+ # ------------------------------------------------------------------
148
+ # Internal helpers
149
+ # ------------------------------------------------------------------
150
+
151
+ def _run_git(self, args: list[str]) -> subprocess.CompletedProcess[str] | None:
152
+ try:
153
+ logger.debug("git %s", " ".join(args))
154
+ return subprocess.run(
155
+ ["git", *args],
156
+ cwd=self.repo_dir,
157
+ capture_output=True,
158
+ text=True,
159
+ encoding="utf-8",
160
+ errors="replace",
161
+ check=False,
162
+ )
163
+ except Exception as exc: # noqa: BLE001
164
+ logger.warning("Git operation failed (%s): %s", " ".join(args), exc)
165
+ return None
166
+
167
+ @staticmethod
168
+ def _format_commit_message(
169
+ *,
170
+ run_id: str,
171
+ cycle: int,
172
+ hypothesis_statuses: dict[str, str],
173
+ description: str,
174
+ ) -> str:
175
+ statuses_json = json.dumps(hypothesis_statuses, sort_keys=True)
176
+ desc = description or "diagnosis complete"
177
+ return (
178
+ f"eval({run_id}) cycle {cycle}: {desc}\n\n"
179
+ f"Hypotheses: {statuses_json}"
180
+ )
181
+
182
+ @staticmethod
183
+ def _clean_output(output: str) -> str:
184
+ return output.strip()
185
+
186
+ @staticmethod
187
+ def _parse_experiment_log_line(line: str) -> dict[str, str] | None:
188
+ pattern = re.compile(r"^([0-9a-fA-F]+)\s+eval\(([^)]+)\)\s+cycle\s+(\d+):\s*(.*)$")
189
+ match = pattern.match(line.strip())
190
+ if match is None:
191
+ return None
192
+ commit_hash, run_id, cycle, message = match.groups()
193
+ return {"hash": commit_hash, "run_id": run_id, "cycle": cycle, "message": message}
194
+
195
+ @staticmethod
196
+ def _log_git_failure(
197
+ operation: str, result: subprocess.CompletedProcess[str] | None
198
+ ) -> None:
199
+ if result is None:
200
+ logger.warning("Git operation failed for %s", operation)
201
+ return
202
+ stderr = result.stderr.strip()
203
+ if stderr:
204
+ logger.warning("Git operation failed for %s: %s", operation, stderr)
205
+ else:
206
+ logger.warning(
207
+ "Git operation failed for %s with code %s", operation, result.returncode
208
+ )
209
+
210
+ def _detect_current_branch(self) -> str | None:
211
+ result = self._run_git(["rev-parse", "--abbrev-ref", "HEAD"])
212
+ if result is None or result.returncode != 0:
213
+ return None
214
+ name = result.stdout.strip()
215
+ return name if name else None