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,351 @@
1
+ """M1 tier (b), white-box — capture-then-compute probe generation.
2
+
3
+ Black-box generated probes (:mod:`probe_generator`) only see the model's text
4
+ outputs. Mechanism hypotheses from M3 ("structural tokens act as attention
5
+ sinks", "attention never leaves the text region", …) need **internal** evidence
6
+ — but the loaded model cannot be shipped into a sandbox. The split here keeps
7
+ the M2-tier(b) safety model intact:
8
+
9
+ 1. **Host captures** (trusted code): one ``model.forward`` per case with
10
+ ``Capability.ATTENTION``; the heavy trace is immediately reduced to a small
11
+ standard bundle — head-averaged attention from the last query position for
12
+ every layer ``(n_layers, seq)`` float16 — plus the token strings and the
13
+ image-token mask, dumped to the sandbox workdir.
14
+ 2. **Sandboxed compute** (generated code): an LLM/CLI-written numpy script
15
+ reads the dump, computes a per-case scalar/boolean signal for the
16
+ hypothesised mechanism, and prints a strict ``PROBE_RESULT_JSON=`` line.
17
+ 3. The parsed findings become a normal :class:`~evalrx.core.result.Result`
18
+ whose ``per_case`` entries flow into M2's stats layer and M4's routing.
19
+
20
+ Dump layout (documented verbatim in the generation prompt):
21
+
22
+ m1_whitebox_manifest.json {"cases": [{"id", "label", "seq_len",
23
+ "n_layers", "tokens": [...]}, ...]}
24
+ m1_whitebox/<case_id>.npz attn_last: (n_layers, seq) float16
25
+ image_token_mask: (seq,) bool
26
+
27
+ Scope (v1): read-only statistics over the captured attention bundle. Causal
28
+ interventions (activation patching, attention knockout) need in-process
29
+ execution with the model handle and are deliberately out of scope.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import logging
36
+ import re
37
+ from dataclasses import dataclass
38
+ from pathlib import Path
39
+ from typing import TYPE_CHECKING, Any, Optional
40
+
41
+ import numpy as np
42
+
43
+ from evalrx.agent_runtime.sandbox import ExperimentSandbox
44
+ from evalrx.core.capability import Capability
45
+ from evalrx.core.result import Result
46
+ from evalrx.eval_agent.prompts.whitebox_probe_generator import (
47
+ _DUMP_DIR,
48
+ _GENERATE_PROMPT,
49
+ _MANIFEST,
50
+ _RESULT_MARKER,
51
+ )
52
+
53
+ if TYPE_CHECKING:
54
+ from evalrx.agent_runtime.cli_types import CliAgentConfig
55
+ from evalrx.core.case import CaseBatch
56
+ from evalrx.core.model import Model
57
+
58
+ logger = logging.getLogger(__name__)
59
+
60
+
61
+
62
+ @dataclass
63
+ class GeneratedWhiteboxProbe:
64
+ """A validated, sandbox-executed white-box probe (re-runnable on new cases)."""
65
+
66
+ name: str
67
+ code: str
68
+ need: str = ""
69
+ source: str = ""
70
+
71
+
72
+ class WhiteboxProbeGenerator:
73
+ """Generate and run capture-then-compute attention probes.
74
+
75
+ Args:
76
+ judge: LLM for the single-pass code-writing path.
77
+ cli_config: CLI coding-agent config (used before the judge when set).
78
+ sandbox: Execution sandbox (fresh temp dir when ``None``).
79
+ timeout_sec: Wall-clock limit per sandbox run.
80
+ max_cases: Cap on captured cases (one ATTENTION forward each); 0 (the default) = every case.
81
+ """
82
+
83
+ def __init__(
84
+ self,
85
+ judge: "Model | None" = None,
86
+ cli_config: "CliAgentConfig | None" = None,
87
+ sandbox: "ExperimentSandbox | None" = None,
88
+ timeout_sec: int = 60,
89
+ max_cases: int = 0,
90
+ run_logger: "Any | None" = None,
91
+ ) -> None:
92
+ self._judge = judge
93
+ self._cli_config = cli_config
94
+ self._timeout_sec = timeout_sec
95
+ self._max_cases = max_cases
96
+ self._sandbox = sandbox or ExperimentSandbox()
97
+ # Optional RunLogger — records each attention-probe code-writing attempt.
98
+ self.run_logger = run_logger
99
+ self._last_prompt: str = ""
100
+ self._last_raw: str = ""
101
+ self._last_raw_stream: str = ""
102
+ self._last_usage: dict | None = None
103
+
104
+ @property
105
+ def available(self) -> bool:
106
+ return self._judge is not None or (
107
+ self._cli_config is not None and self._cli_config.provider != "llm"
108
+ )
109
+
110
+ # ------------------------------------------------------------------
111
+ # Public interface
112
+ # ------------------------------------------------------------------
113
+
114
+ def generate(
115
+ self,
116
+ need: str,
117
+ model: "Model",
118
+ cases: "CaseBatch",
119
+ name: str = "custom",
120
+ ) -> "tuple[Result | None, GeneratedWhiteboxProbe | None]":
121
+ """Capture internals, write+run a probe over the dump, return (Result, probe)."""
122
+ if not self.available:
123
+ logger.debug("WhiteboxProbeGenerator: no code-writing backend configured")
124
+ return None, None
125
+ if Capability.ATTENTION not in getattr(model, "capabilities", frozenset()):
126
+ logger.debug("WhiteboxProbeGenerator: model lacks ATTENTION capability")
127
+ return None, None
128
+
129
+ n = self._capture(model, cases)
130
+ if n == 0:
131
+ logger.warning("WhiteboxProbeGenerator: no case could be captured")
132
+ return None, None
133
+
134
+ self._last_prompt = ""
135
+ self._last_raw = ""
136
+ self._last_raw_stream = ""
137
+ self._last_usage = None
138
+ try:
139
+ code, source = self._write_code(need)
140
+ except Exception as exc:
141
+ logger.warning("WhiteboxProbeGenerator: code writing failed: %s", exc)
142
+ self._emit_codegen(name, need, "", "", ok=False, error=f"code writing failed: {exc}")
143
+ return None, None
144
+ if not code.strip():
145
+ self._emit_codegen(name, need, source, "", ok=False, error="empty code produced")
146
+ return None, None
147
+
148
+ result = self._run_code(code, name, model, cases)
149
+ self._emit_codegen(
150
+ name, need, source, code, ok=result is not None,
151
+ error="" if result is not None else "sandbox produced no parseable result",
152
+ )
153
+ if result is None:
154
+ return None, None
155
+ probe = GeneratedWhiteboxProbe(name=name, code=code, need=need, source=source)
156
+ return result, probe
157
+
158
+ def _emit_codegen(
159
+ self, name: str, need: str, source: str, code: str, *, ok: bool, error: str = ""
160
+ ) -> None:
161
+ """Record one attention-probe code-writing attempt to the RunLogger."""
162
+ if self.run_logger is None:
163
+ return
164
+ extra = ({"cli_usage": self._last_usage}
165
+ if source.startswith("cli:") and self._last_usage else None)
166
+ try:
167
+ self.run_logger.log_tool_codegen(
168
+ module="m1_whitebox", name=name, need=need, source=source, ok=ok,
169
+ code=code, prompt=self._last_prompt, raw_output=self._last_raw,
170
+ raw_stream=self._last_raw_stream, error=error,
171
+ extra=extra,
172
+ )
173
+ except Exception as exc: # logging must never break generation
174
+ logger.debug("WhiteboxProbeGenerator: log_tool_codegen failed: %s", exc)
175
+
176
+ def run_cached(
177
+ self,
178
+ probe: GeneratedWhiteboxProbe,
179
+ model: "Model",
180
+ cases: "CaseBatch",
181
+ ) -> "Result | None":
182
+ """Re-capture and re-run an already-generated probe (no LLM call)."""
183
+ if self._capture(model, cases) == 0:
184
+ return None
185
+ return self._run_code(probe.code, probe.name, model, cases)
186
+
187
+ # ------------------------------------------------------------------
188
+ # Host capture (trusted)
189
+ # ------------------------------------------------------------------
190
+
191
+ def _capture(self, model: "Model", cases: "CaseBatch") -> int:
192
+ """Forward each case, reduce, and dump the attention bundle. Returns n captured."""
193
+ workdir = Path(self._sandbox.workdir)
194
+ dump_dir = workdir / _DUMP_DIR
195
+ dump_dir.mkdir(parents=True, exist_ok=True)
196
+
197
+ manifest: list[dict[str, Any]] = []
198
+ selected = list(cases)
199
+ if self._max_cases > 0: # 0 = every case
200
+ selected = selected[: self._max_cases]
201
+ if getattr(self.run_logger, "preserve_full_model_io", False):
202
+ from evalrx.eval_agent.model_instrumentation import InstrumentedModel
203
+
204
+ model = InstrumentedModel(
205
+ model, self.run_logger,
206
+ cycle=int(getattr(self.run_logger, "current_cycle", -1)),
207
+ analyzer="generated_whitebox_probe_capture",
208
+ case_prompts={c.inputs.prompt: c.id for c in selected},
209
+ batch_case_ids=[c.id for c in selected],
210
+ )
211
+ for case in selected:
212
+ try:
213
+ trace = model.forward(case.inputs, capture={Capability.ATTENTION})
214
+ attns = trace.require(Capability.ATTENTION)
215
+ rows = np.stack([
216
+ a.float().mean(dim=0)[-1].cpu().numpy() for a in attns
217
+ ]).astype(np.float16) # (n_layers, seq)
218
+ mask = trace.extras.get("image_token_mask")
219
+ mask_np = (
220
+ mask.cpu().numpy().astype(bool)
221
+ if mask is not None else np.zeros(rows.shape[1], dtype=bool)
222
+ )
223
+ tokens = list(trace.tokens)
224
+ except Exception as exc: # noqa: BLE001 - skip uncapturable cases
225
+ logger.debug("whitebox capture failed for %s: %s", case.id, exc)
226
+ continue
227
+ finally:
228
+ trace = None # free the heavy attention tensors promptly
229
+
230
+ np.savez_compressed(
231
+ dump_dir / f"{case.id}.npz",
232
+ attn_last=rows, image_token_mask=mask_np,
233
+ )
234
+ label = getattr(case, "label", None)
235
+ manifest.append({
236
+ "id": case.id,
237
+ "label": getattr(label, "value", None),
238
+ "seq_len": int(rows.shape[1]),
239
+ "n_layers": int(rows.shape[0]),
240
+ "tokens": tokens,
241
+ })
242
+
243
+ (workdir / _MANIFEST).write_text(
244
+ json.dumps({"cases": manifest}), encoding="utf-8"
245
+ )
246
+ return len(manifest)
247
+
248
+ # ------------------------------------------------------------------
249
+ # Code writing + sandbox run (mirrors probe_generator)
250
+ # ------------------------------------------------------------------
251
+
252
+ def _write_code(self, need: str) -> tuple[str, str]:
253
+ if self._cli_config is not None and self._cli_config.provider != "llm":
254
+ code = self._write_code_cli(need)
255
+ if code:
256
+ return code, f"cli:{self._cli_config.provider}"
257
+ prompt = self._build_prompt(need, fenced=True)
258
+ self._last_prompt = prompt
259
+ raw = self._judge.generate(prompt) # type: ignore[union-attr]
260
+ self._last_raw = str(raw)
261
+ return _extract_code(str(raw)), "llm"
262
+
263
+ def _write_code_cli(self, need: str) -> str:
264
+ from evalrx.agent_runtime.codegen import CodegenRunner
265
+
266
+ prompt = self._build_prompt(need, fenced=False)
267
+ self._last_prompt = prompt
268
+ result = CodegenRunner(self._cli_config).write_code( # type: ignore[arg-type]
269
+ prompt,
270
+ workdir=Path(self._sandbox.workdir),
271
+ timeout_sec=self._timeout_sec,
272
+ preferred_filenames=("probe.py",),
273
+ )
274
+ self._last_raw = result.raw_output
275
+ self._last_raw_stream = ""
276
+ if result.raw_stream_path:
277
+ try:
278
+ self._last_raw_stream = (
279
+ Path(self._sandbox.workdir) / result.raw_stream_path
280
+ ).read_text(encoding="utf-8")
281
+ except OSError:
282
+ pass
283
+ self._last_usage = result.usage
284
+ return result.code
285
+
286
+ def _build_prompt(self, need: str, *, fenced: bool) -> str:
287
+ return _GENERATE_PROMPT.format(
288
+ need=need.strip() or "Probe the attention pattern behind the failures.",
289
+ manifest=_MANIFEST,
290
+ dump_dir=_DUMP_DIR,
291
+ marker=_RESULT_MARKER,
292
+ fences_hint=" inside a ```python code block" if fenced else
293
+ ", written to a file named probe.py",
294
+ )
295
+
296
+ def _run_code(
297
+ self, code: str, name: str, model: "Model", cases: "CaseBatch",
298
+ ) -> "Result | None":
299
+ sandbox_result = self._sandbox.run(code, timeout_sec=self._timeout_sec)
300
+ if not sandbox_result.ok:
301
+ logger.warning(
302
+ "WhiteboxProbeGenerator: sandbox run failed (rc=%s): %s",
303
+ sandbox_result.returncode, (sandbox_result.stderr or "").strip()[:200],
304
+ )
305
+ return None
306
+ return _parse_result(sandbox_result.stdout, name, model, cases)
307
+
308
+
309
+ # ---------------------------------------------------------------------------
310
+ # Module helpers (shared shape with probe_generator)
311
+ # ---------------------------------------------------------------------------
312
+
313
+ def _extract_code(raw: str) -> str:
314
+ cleaned = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL)
315
+ fence = re.search(r"```(?:python)?\s*\n(.*?)```", cleaned, flags=re.DOTALL)
316
+ if fence:
317
+ return fence.group(1).strip()
318
+ return cleaned.strip()
319
+
320
+
321
+ def _parse_result(
322
+ stdout: str, name: str, model: "Model", cases: "CaseBatch",
323
+ ) -> "Result | None":
324
+ marker_line: Optional[str] = None
325
+ for line in stdout.splitlines():
326
+ s = line.strip()
327
+ if s.startswith(_RESULT_MARKER):
328
+ marker_line = s[len(_RESULT_MARKER):]
329
+ if marker_line is None:
330
+ logger.warning("WhiteboxProbeGenerator: no PROBE_RESULT_JSON line in output")
331
+ return None
332
+ try:
333
+ data = json.loads(marker_line)
334
+ except json.JSONDecodeError as exc:
335
+ logger.warning("WhiteboxProbeGenerator: unparseable PROBE_RESULT_JSON: %s", exc)
336
+ return None
337
+
338
+ findings: dict[str, Any] = {}
339
+ if isinstance(data.get("findings"), dict):
340
+ findings.update(data["findings"])
341
+ per_case = data.get("per_case")
342
+ if isinstance(per_case, list):
343
+ findings["per_case"] = [e for e in per_case if isinstance(e, dict)]
344
+
345
+ return Result(
346
+ analyzer=f"generated_wb:{name}",
347
+ model=repr(model),
348
+ cases=cases,
349
+ findings=findings,
350
+ metadata={"generated": True, "whitebox": True, "probe": name},
351
+ )
@@ -0,0 +1,231 @@
1
+ """Store — the persistent memory that makes self-evolution possible.
2
+
3
+ Without memory, an agent re-explores the same ground forever. The store
4
+ accumulates the corpus the agent evolves over: cases it has constructed,
5
+ results it has produced, and hypotheses it has tested. The loop writes to it
6
+ each cycle and reads from it to decide what to try next.
7
+
8
+ This module defines the interface, an in-memory reference, and a JSONL-backed
9
+ persistent implementation (JsonlStore).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import logging
16
+ from abc import ABC, abstractmethod
17
+ from pathlib import Path
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ if TYPE_CHECKING:
21
+ from evalrx.core.case import FailureCase
22
+ from evalrx.core.result import Result
23
+ from evalrx.eval_agent.hypothesis import Hypothesis
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ class Store(ABC):
29
+ """Persistent memory interface for the self-evolving loop."""
30
+
31
+ @abstractmethod
32
+ def add_case(self, case: "FailureCase") -> None: ...
33
+
34
+ @abstractmethod
35
+ def add_result(self, result: "Result") -> None: ...
36
+
37
+ @abstractmethod
38
+ def add_hypothesis(self, hypothesis: "Hypothesis") -> None: ...
39
+
40
+ @abstractmethod
41
+ def query(self, **filters: Any) -> list[Any]:
42
+ """Retrieve stored items matching *filters* (tags, label, status, …)."""
43
+
44
+ @abstractmethod
45
+ def summarize(self) -> dict[str, Any]:
46
+ """Aggregate view the agent reads to spot gaps and patterns."""
47
+
48
+
49
+ class InMemoryStore(Store):
50
+ """Minimal non-persistent reference store (Stage-2 will add durability)."""
51
+
52
+ def __init__(self) -> None:
53
+ self.cases: list[FailureCase] = []
54
+ self.results: list[Result] = []
55
+ self.hypotheses: list[Hypothesis] = []
56
+
57
+ def add_case(self, case: "FailureCase") -> None:
58
+ self.cases.append(case)
59
+
60
+ def add_result(self, result: "Result") -> None:
61
+ self.results.append(result)
62
+
63
+ def add_hypothesis(self, hypothesis: "Hypothesis") -> None:
64
+ self.hypotheses.append(hypothesis)
65
+
66
+ def query(self, kind: str | None = None, **filters: Any) -> list[Any]:
67
+ """Retrieve stored items. ``kind`` in {cases, results, hypotheses}; filters:
68
+ ``label`` / ``tags`` (cases), ``status`` (hypotheses), ``analyzer`` (results)."""
69
+ out: list[Any] = []
70
+ if kind in (None, "cases"):
71
+ for c in self.cases:
72
+ if filters.get("label") is not None and getattr(c, "label", None) != filters["label"]:
73
+ continue
74
+ tags = filters.get("tags")
75
+ if tags is not None and not set(tags).issubset(getattr(c, "tags", set())):
76
+ continue
77
+ out.append(c)
78
+ if kind in (None, "results"):
79
+ for r in self.results:
80
+ if filters.get("analyzer") is not None and getattr(r, "analyzer", None) != filters["analyzer"]:
81
+ continue
82
+ out.append(r)
83
+ if kind in (None, "hypotheses"):
84
+ for h in self.hypotheses:
85
+ if filters.get("status") is not None and getattr(h, "status", None) != filters["status"]:
86
+ continue
87
+ out.append(h)
88
+ return out
89
+
90
+ def summarize(self) -> dict[str, Any]:
91
+ return {
92
+ "n_cases": len(self.cases),
93
+ "n_results": len(self.results),
94
+ "n_hypotheses": len(self.hypotheses),
95
+ }
96
+
97
+
98
+ class JsonlStore(Store):
99
+ """Persistent JSONL-backed store.
100
+
101
+ Files written under *store_dir*:
102
+ - ``hypotheses.jsonl`` — one serialised :class:`Hypothesis` per line
103
+ - ``results.jsonl`` — one scalar-only result summary per line
104
+ - ``cases.jsonl`` — one case summary per line
105
+
106
+ Hypotheses survive process restart; cases and results store lightweight
107
+ summaries only (no numpy arrays or heavy objects).
108
+
109
+ Thread safety: appends use ``O_APPEND`` which is atomic on Linux for
110
+ small writes; reads reload from disk each call.
111
+ """
112
+
113
+ def __init__(self, store_dir: Path | str) -> None:
114
+ self._dir = Path(store_dir)
115
+ self._dir.mkdir(parents=True, exist_ok=True)
116
+ self._hyp_path = self._dir / "hypotheses.jsonl"
117
+ self._res_path = self._dir / "results.jsonl"
118
+ self._case_path = self._dir / "cases.jsonl"
119
+
120
+ # ------------------------------------------------------------------
121
+ # Store interface
122
+ # ------------------------------------------------------------------
123
+
124
+ def add_case(self, case: "FailureCase") -> None:
125
+ record: dict[str, Any] = {
126
+ "id": getattr(case, "id", ""),
127
+ "label": getattr(case, "label", None),
128
+ "tags": list(getattr(case, "tags", [])),
129
+ "prompt_snippet": str(getattr(getattr(case, "inputs", None), "prompt", ""))[:200],
130
+ }
131
+ self._append(self._case_path, record)
132
+
133
+ def add_result(self, result: "Result") -> None:
134
+ findings = getattr(result, "findings", {}) or {}
135
+ scalar_findings: dict[str, Any] = {}
136
+ for k, v in findings.items():
137
+ try:
138
+ scalar_findings[k] = float(v) if hasattr(v, "__float__") else str(v)
139
+ except Exception:
140
+ scalar_findings[k] = str(v)
141
+ record: dict[str, Any] = {
142
+ "analyzer": getattr(result, "analyzer", ""),
143
+ "findings": scalar_findings,
144
+ "metadata": {
145
+ k: str(v) for k, v in (getattr(result, "metadata", {}) or {}).items()
146
+ if isinstance(k, str)
147
+ },
148
+ }
149
+ self._append(self._res_path, record)
150
+
151
+ def add_hypothesis(self, hypothesis: "Hypothesis") -> None:
152
+ from evalrx.eval_agent.hypothesis import hypothesis_to_dict
153
+ self._append(self._hyp_path, hypothesis_to_dict(hypothesis))
154
+
155
+ def query(self, kind: str | None = None, **filters: Any) -> list[Any]:
156
+ """Retrieve stored items matching *filters*.
157
+
158
+ Returns reconstructed :class:`Hypothesis` objects for ``kind="hypotheses"``;
159
+ raw dicts for cases and results.
160
+ """
161
+ out: list[Any] = []
162
+
163
+ if kind in (None, "cases"):
164
+ for rec in self._load(self._case_path):
165
+ if filters.get("label") is not None and rec.get("label") != filters["label"]:
166
+ continue
167
+ tags = filters.get("tags")
168
+ if tags is not None and not set(tags).issubset(set(rec.get("tags", []))):
169
+ continue
170
+ out.append(rec)
171
+
172
+ if kind in (None, "results"):
173
+ for rec in self._load(self._res_path):
174
+ if filters.get("analyzer") is not None and rec.get("analyzer") != filters["analyzer"]:
175
+ continue
176
+ out.append(rec)
177
+
178
+ if kind in (None, "hypotheses"):
179
+ from evalrx.eval_agent.hypothesis import hypothesis_from_dict
180
+ for rec in self._load(self._hyp_path):
181
+ h = hypothesis_from_dict(rec)
182
+ if filters.get("status") is not None:
183
+ wanted = filters["status"]
184
+ # Accept both enum and string comparisons
185
+ h_status = h.status.value if h.status else None
186
+ if wanted != h.status and wanted != h_status:
187
+ continue
188
+ out.append(h)
189
+
190
+ return out
191
+
192
+ def summarize(self) -> dict[str, Any]:
193
+ n_hyp = sum(1 for _ in self._load(self._hyp_path))
194
+ n_res = sum(1 for _ in self._load(self._res_path))
195
+ n_cas = sum(1 for _ in self._load(self._case_path))
196
+ return {
197
+ "n_cases": n_cas,
198
+ "n_results": n_res,
199
+ "n_hypotheses": n_hyp,
200
+ }
201
+
202
+ # ------------------------------------------------------------------
203
+ # Internal helpers
204
+ # ------------------------------------------------------------------
205
+
206
+ @staticmethod
207
+ def _append(path: Path, record: dict[str, Any]) -> None:
208
+ try:
209
+ with path.open("a", encoding="utf-8") as f:
210
+ f.write(json.dumps(record, default=str) + "\n")
211
+ except OSError as exc:
212
+ logger.warning("JsonlStore: failed to append to %s: %s", path, exc)
213
+
214
+ @staticmethod
215
+ def _load(path: Path) -> list[dict[str, Any]]:
216
+ if not path.exists():
217
+ return []
218
+ records: list[dict[str, Any]] = []
219
+ try:
220
+ with path.open(encoding="utf-8") as f:
221
+ for line in f:
222
+ line = line.strip()
223
+ if not line:
224
+ continue
225
+ try:
226
+ records.append(json.loads(line))
227
+ except json.JSONDecodeError:
228
+ logger.debug("JsonlStore: skipping malformed line in %s", path)
229
+ except OSError as exc:
230
+ logger.warning("JsonlStore: failed to read %s: %s", path, exc)
231
+ return records
@@ -0,0 +1,112 @@
1
+ """Console logging convenience for evalrx' existing per-module loggers.
2
+
3
+ Every submodule follows the standard library idiom
4
+ ``logger = logging.getLogger(__name__)`` and already narrates real events —
5
+ M1-M5 stage transitions, capability negotiation, paper-method dispatch
6
+ (``vcd``/``icd``/...), fix-candidate generation and validation, audio decode
7
+ fallbacks. None of it is visible by default: Python's logging module only
8
+ invokes an implicit "handler of last resort" for ``WARNING``-and-above
9
+ records when NO handler exists anywhere in a logger's propagation chain, and
10
+ even that prints unformatted straight to stderr. ``.info()``/``.debug()``
11
+ calls — most of the narration — never appear at all without a handler.
12
+
13
+ A **library** must never call ``logging.basicConfig()`` itself: that
14
+ configures the ROOT logger process-wide and would clobber a host
15
+ application's own logging setup the moment ``import evalrx`` runs. The
16
+ standard, documented pattern (see the stdlib logging HOWTO's "Configuring
17
+ Logging for a Library" section) is instead:
18
+
19
+ * attach a :class:`logging.NullHandler` to the package's top-level logger
20
+ (done in :mod:`evalrx` on import) so the library is silent — no
21
+ "No handlers could be found" noise, no surprise raw stderr output — until
22
+ a caller opts in;
23
+ * expose an explicit, opt-in helper for callers who *do* want to see it.
24
+ That's this module.
25
+
26
+ Usage::
27
+
28
+ import evalrx
29
+ evalrx.enable_console_logging() # INFO and above, readable
30
+ model = evalrx.load("qwen2.5-7b-instruct")
31
+ ... # now narrated to stdout
32
+
33
+ evalrx.enable_console_logging(level=logging.DEBUG) # noisier
34
+ evalrx.disable_console_logging() # back to quiet
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import logging
40
+ import sys
41
+ from typing import IO, Optional
42
+
43
+ # Every module's `logging.getLogger(__name__)` (e.g. "evalrx.eval_agent.loop")
44
+ # is a child of this in the dot-hierarchy, so one handler here reaches all of
45
+ # them via normal propagation -- except RunLogger's own "evalrx.run.<dir>"
46
+ # tree, which sets propagate=False by design to keep its structured JSONL
47
+ # event stream a separate channel from this ad-hoc narration.
48
+ TOP_LEVEL_LOGGER_NAME = "evalrx"
49
+
50
+ # Marks a handler as "ours" so enable_console_logging() is idempotent (calling
51
+ # it twice reconfigures the level instead of stacking a second handler) and
52
+ # disable_console_logging() only ever removes handlers it installed itself --
53
+ # never a caller's own handler on the same logger.
54
+ _MARKER_ATTR = "_evalrx_console_handler"
55
+
56
+
57
+ class _ConsoleFormatter(logging.Formatter):
58
+ """Compact single-line format: time, level, short module name, message."""
59
+
60
+ def format(self, record: logging.LogRecord) -> str:
61
+ record.short_name = record.name.rsplit(".", 1)[-1]
62
+ return super().format(record)
63
+
64
+
65
+ def enable_console_logging(
66
+ level: int = logging.INFO, *, stream: Optional[IO[str]] = None
67
+ ) -> logging.Handler:
68
+ """Attach a readable stdout handler to evalrx' logger tree.
69
+
70
+ Idempotent: a second call reconfigures the level of the existing handler
71
+ rather than attaching a duplicate (no doubled lines).
72
+
73
+ Args:
74
+ level: Minimum level to show. ``logging.INFO`` (default) surfaces the
75
+ stage-by-stage narration most callers want; ``logging.DEBUG``
76
+ is noisier (per-candidate detail, low-level capability checks).
77
+ stream: Defaults to ``sys.stdout``.
78
+
79
+ Returns:
80
+ The attached :class:`logging.Handler`, in case a caller wants to
81
+ remove or further customise it directly.
82
+ """
83
+ top_logger = logging.getLogger(TOP_LEVEL_LOGGER_NAME)
84
+ for handler in top_logger.handlers:
85
+ if getattr(handler, _MARKER_ATTR, False):
86
+ handler.setLevel(level)
87
+ top_logger.setLevel(level)
88
+ return handler
89
+
90
+ handler = logging.StreamHandler(stream or sys.stdout)
91
+ handler.setFormatter(
92
+ _ConsoleFormatter(
93
+ fmt="%(asctime)s %(levelname)-7s %(short_name)s: %(message)s",
94
+ datefmt="%H:%M:%S",
95
+ )
96
+ )
97
+ setattr(handler, _MARKER_ATTR, True)
98
+ top_logger.addHandler(handler)
99
+ top_logger.setLevel(level)
100
+ return handler
101
+
102
+
103
+ def disable_console_logging() -> None:
104
+ """Remove the handler(s) installed by :func:`enable_console_logging`.
105
+
106
+ A no-op if none was ever attached. Never touches handlers a caller added
107
+ themselves directly to ``logging.getLogger("evalrx")`` or below.
108
+ """
109
+ top_logger = logging.getLogger(TOP_LEVEL_LOGGER_NAME)
110
+ for handler in list(top_logger.handlers):
111
+ if getattr(handler, _MARKER_ATTR, False):
112
+ top_logger.removeHandler(handler)