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
evalrx/viz/renderer.py ADDED
@@ -0,0 +1,590 @@
1
+ """Host-side, deterministic chart rendering for exploratory artifacts.
2
+
3
+ The explorer (a CLI coding agent) only *proposes* chart specifications — a small
4
+ dict ``{name, kind, data, x, y, title}`` where ``data`` points to a CSV table it
5
+ wrote. This module renders those specs to PNG **on the host**, from the spec and
6
+ the CSV alone: it NEVER executes LLM-authored plotting code. That keeps the
7
+ visual layer auditable and reproducible (same spec + same CSV → same figure).
8
+
9
+ Rendering is best-effort and never raises into a pipeline:
10
+ - ``matplotlib`` missing → every spec is returned with a ``render_skipped`` note
11
+ and a free-text ``description``; callers fall back to the text.
12
+ - A spec that can't be rendered (missing CSV, unknown columns, empty table) is
13
+ returned annotated, not dropped — discovery output is preserved.
14
+
15
+ This is the single visualization core shared by every single-shot entry
16
+ (``evalrx explore``, the fused pipeline, and the in-loop M3 view).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import csv
22
+ import logging
23
+ import re
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from evalrx.viz.labels import display_name
28
+ from evalrx.viz.style import (
29
+ NATURE_COLORS_FALLBACK,
30
+ SEMANTIC_PALETTE,
31
+ load_nature_style,
32
+ outcome_color,
33
+ )
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ _KINDS = {"bar", "line", "scatter", "timeseries", "forest"}
38
+
39
+ # Chart-type policy (agent-side counterpart: the eval-chart-style skill).
40
+ # "A bar's filled area means amount accumulated from zero — use bars only for
41
+ # counts." Rates, proportions, means and effect sizes must not be bars.
42
+ _COUNT_Y = re.compile(r"(count$|^n$|_n$|n_cases|ncases|num_|_num$|samples|draws|frequency|^total$)", re.I)
43
+ _EFFECT_Y = re.compile(r"(effect|separation|smd|odds|ratio|coefficient|importance|weight|score|mean|avg|average|delta|diff|lift|gain)", re.I)
44
+
45
+
46
+ def _is_count_like(y: Any, ys: list[float]) -> bool:
47
+ """True only when the y column plausibly holds raw counts."""
48
+ name = str(y or "")
49
+ if _EFFECT_Y.search(name):
50
+ return False # a mean/effect column is never a count even if integer-valued
51
+ if not _COUNT_Y.search(name):
52
+ return False
53
+ return all(float(v).is_integer() for v in ys)
54
+
55
+
56
+ def _demote_bar(spec: dict[str, Any], y: Any, ys: list[float]) -> str:
57
+ """Pick the policy-compliant kind a 'bar' spec must be rendered as instead."""
58
+ return "forest" if _EFFECT_Y.search(str(y or "")) else "line"
59
+
60
+ #: Column names that mean "this y is a count" (composition / count bars).
61
+ _COUNT_COLUMNS = {"count", "counts", "n", "n_cases", "cases", "num", "total", "freq", "frequency"}
62
+ #: Column names that carry the per-group / per-bin sample size.
63
+ _N_COLUMNS = ("n", "n_cases", "n_rows", "support", "cases", "count")
64
+ #: (low, high) column-name pairs the explorer may use for a 95% interval.
65
+ _CI_COLUMNS = (("ci_low", "ci_high"), ("ci_lo", "ci_hi"), ("ci95_low", "ci95_high"),
66
+ ("lower", "upper"), ("lo", "hi"), ("ymin", "ymax"))
67
+ _RATE_WORDS = ("rate", "pct", "percent", "share", "frac", "fraction", "proportion", "prob")
68
+ #: Up to this many groups a group->value comparison is a dot + CI / lollipop,
69
+ #: never bars (eval-chart-style §0: a bar's area means "accumulated from 0").
70
+ _DOT_MAX_GROUPS = 3
71
+
72
+
73
+ def _count_like(y: Any, ys: list[float]) -> bool:
74
+ """Count-valued y for the bar policy: a known count column (``count``,
75
+ ``n``, ...), an ``n_<group>`` / ``num_<x>`` numerator column, or
76
+ :func:`_is_count_like`'s name test — and integer values in every case."""
77
+ name = str(y or "").lower()
78
+ if not ys or not all(float(v).is_integer() for v in ys):
79
+ return False
80
+ return name in _COUNT_COLUMNS or name.startswith(("n_", "num_")) or _is_count_like(y, ys)
81
+
82
+
83
+ def render_chart_specs(
84
+ charts: list[dict[str, Any]] | None,
85
+ tables_dir: str | Path | None,
86
+ out_dir: str | Path,
87
+ ) -> list[dict[str, Any]]:
88
+ """Render each chart spec to a PNG under *out_dir*, deterministically.
89
+
90
+ Args:
91
+ charts: Explorer chart specs. Each is ``{name, kind, data, x, y,
92
+ title}`` where ``data`` is a CSV path (relative to
93
+ *tables_dir*, or absolute).
94
+ tables_dir: Directory the spec ``data`` CSVs live in (the explorer's
95
+ ``tables/``). ``None`` resolves CSVs relative to *out_dir*'s
96
+ parent.
97
+ out_dir: Directory to write ``figures/`` PNGs into (created lazily).
98
+
99
+ Returns:
100
+ A new list of chart dicts, each a shallow copy of the input spec plus:
101
+ - ``figure_path``: absolute PNG path when rendered;
102
+ - ``description``: a one-line textual summary (always set);
103
+ - ``render_skipped``: reason string when no PNG was produced.
104
+
105
+ The input list is not mutated; ordering is preserved.
106
+ """
107
+ specs = [dict(c) for c in (charts or []) if isinstance(c, dict)]
108
+ if not specs:
109
+ return specs
110
+
111
+ out_dir = Path(out_dir)
112
+ tdir = Path(tables_dir) if tables_dir else None
113
+ plt = _import_matplotlib()
114
+ style = load_nature_style()
115
+
116
+ rendered: list[dict[str, Any]] = []
117
+ for idx, spec in enumerate(specs):
118
+ rows, load_err = _load_table(spec.get("data"), tdir, out_dir)
119
+ x = spec.get("x")
120
+ y = spec.get("y")
121
+
122
+ if plt is None:
123
+ spec["render_skipped"] = "matplotlib not installed (pip install 'evalrx[viz]')"
124
+ spec.setdefault("description", _describe(spec, rows, x, y))
125
+ rendered.append(spec)
126
+ continue
127
+
128
+ ok, reason = _can_render(rows, x, y)
129
+ if not ok:
130
+ spec["render_skipped"] = load_err or reason
131
+ spec.setdefault("description", _describe(spec, rows, x, y))
132
+ rendered.append(spec)
133
+ continue
134
+
135
+ try:
136
+ png = _render_one(plt, spec, rows, x, y, out_dir, idx, style)
137
+ spec["figure_path"] = str(png)
138
+ spec.pop("render_skipped", None)
139
+ except Exception as exc: # rendering must never sink the caller
140
+ logger.warning("render_chart_specs: chart %d failed: %s", idx, exc)
141
+ spec["render_skipped"] = f"render error: {exc}"
142
+ # After rendering, so the caption names the form actually drawn.
143
+ spec.setdefault("description", _describe(spec, rows, x, y))
144
+ rendered.append(spec)
145
+
146
+ return rendered
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # internals
151
+ # ---------------------------------------------------------------------------
152
+
153
+ def _import_matplotlib():
154
+ try:
155
+ import matplotlib
156
+
157
+ matplotlib.use("Agg") # headless, no display needed
158
+ # Arial/Helvetica are usually absent on servers; DejaVu Sans is the
159
+ # deterministic fallback. Silence the per-figure findfont warning spam.
160
+ import logging as _logging
161
+
162
+ import matplotlib.pyplot as plt
163
+ _logging.getLogger("matplotlib.font_manager").setLevel(_logging.ERROR)
164
+ return plt
165
+ except Exception:
166
+ return None
167
+
168
+
169
+ def _load_table(
170
+ data: Any, tables_dir: Path | None, out_dir: Path
171
+ ) -> tuple[list[dict[str, str]] | None, str]:
172
+ """Resolve and read the spec's CSV into a list of row dicts.
173
+
174
+ Returns ``(rows, "")`` on success, ``(None, reason)`` otherwise.
175
+ """
176
+ if not data:
177
+ return None, "no data table referenced"
178
+ if not isinstance(data, str):
179
+ # inline list-of-dicts table (rare; the explorer usually writes CSV)
180
+ if isinstance(data, list) and data and isinstance(data[0], dict):
181
+ return [{str(k): str(v) for k, v in r.items()} for r in data], ""
182
+ return None, "unsupported inline data shape"
183
+
184
+ path = Path(data)
185
+ candidates: list[Path] = []
186
+ if path.is_absolute():
187
+ candidates.append(path)
188
+ else:
189
+ if tables_dir is not None:
190
+ candidates.append(tables_dir / path) # data="tables/foo.csv"
191
+ candidates.append(tables_dir / path.name) # data="foo.csv"
192
+ candidates.append(tables_dir / "tables" / path.name) # tables_dir=sandbox
193
+ candidates.append(out_dir / path)
194
+ candidates.append(out_dir / "tables" / path.name)
195
+ candidates.append(out_dir.parent / path)
196
+ resolved = next((p for p in candidates if p.exists()), None)
197
+ if resolved is None:
198
+ return None, f"CSV not found: {data}"
199
+ if resolved.suffix.lower() != ".csv":
200
+ return None, f"not a CSV: {resolved.name}"
201
+ try:
202
+ # errors="replace" tolerates non-UTF-8 cells (VL logs carry latin-1/binary
203
+ # text); the broad except covers csv.Error (oversized field) so a malformed
204
+ # CSV degrades to a (None, reason) annotation instead of raising — the
205
+ # module's "never raises into a pipeline" contract.
206
+ with resolved.open("r", encoding="utf-8", errors="replace", newline="") as fh:
207
+ rows = list(csv.DictReader(fh))
208
+ except (OSError, UnicodeError, csv.Error) as exc:
209
+ return None, f"could not read {resolved.name}: {exc}"
210
+ if not rows:
211
+ return None, "empty CSV"
212
+ return rows, ""
213
+
214
+
215
+ def _can_render(rows: list[dict[str, str]] | None, x: Any, y: Any) -> tuple[bool, str]:
216
+ if not rows:
217
+ return False, "no table data"
218
+ if not x or not y:
219
+ return False, "spec missing x or y column"
220
+ cols = rows[0].keys()
221
+ if x not in cols:
222
+ return False, f"x column {x!r} not in table"
223
+ if y not in cols:
224
+ return False, f"y column {y!r} not in table"
225
+ return True, ""
226
+
227
+
228
+ def _to_float(value: str) -> float | None:
229
+ try:
230
+ return float(value)
231
+ except (TypeError, ValueError):
232
+ return None
233
+
234
+
235
+ def _get_bar_colors(xs_raw: list[Any], default_palette: list[str]) -> list[str]:
236
+ """Map categories to semantic palette colors (FAIL-red, PASS-green, else palette)."""
237
+ colors = []
238
+ for idx, label in enumerate(xs_raw):
239
+ s = str(label).strip().lower()
240
+ if any(w in s for w in ("fail", "broken", "error", "loss", "regression")):
241
+ colors.append("#d03b3b")
242
+ elif any(w in s for w in ("pass", "fixed", "cured", "correct", "gain", "survivor")):
243
+ colors.append("#0ca30c")
244
+ elif any(w in s for w in ("inconclusive", "warn", "unverified", "middle")):
245
+ colors.append("#fab219")
246
+ else:
247
+ colors.append(default_palette[idx % len(default_palette)])
248
+ return colors
249
+
250
+
251
+ def _render_one(plt, spec, rows, x, y, out_dir, idx, style) -> Path:
252
+ """Draw one spec. The chart FORM follows the eval-chart-style policy, not
253
+ the spec's ``kind`` alone (the explorer may only say bar/line/scatter):
254
+
255
+ * ``kind=bar`` whose y is a count over a handful of classes (class
256
+ balance) -> ONE 100%-stacked composition strip;
257
+ * ``kind=bar`` comparing a rate/mean across <= 3 groups
258
+ -> horizontal dot + 95% CI (Wilson from ``n`` for a
259
+ rate, or the CSV's own ci_low/ci_high), lollipop
260
+ when no interval can be formed — never two bars;
261
+ * other bars -> bars, FAIL/PASS hues on outcome axes, n annotated;
262
+ * line / scatter -> as before, aliased axes, n annotated on lines.
263
+
264
+ Every PNG is deterministic (same spec + CSV -> byte-identical file) and the
265
+ chosen form is recorded in ``spec["rendered_as"]``.
266
+ """
267
+ kind = str(spec.get("kind", "bar")).lower()
268
+ if kind not in _KINDS:
269
+ kind = "bar"
270
+ xs_raw = [r.get(x, "") for r in rows]
271
+ ys = [_to_float(r.get(y, "")) for r in rows]
272
+ keep = [i for i, yv in enumerate(ys) if yv is not None]
273
+ if not keep:
274
+ raise ValueError(f"y column {y!r} has no numeric values")
275
+ rows = [rows[i] for i in keep]
276
+ xs_raw = [xs_raw[i] for i in keep]
277
+ ys = [ys[i] for i in keep]
278
+ xs_num = [_to_float(v) for v in xs_raw]
279
+ x_is_num = all(v is not None for v in xs_num)
280
+ ns = _n_values(rows, y)
281
+ scale = _rate_scale(y, ys)
282
+ rate = scale is not None
283
+ rc = (style or {}).get("rc", {})
284
+ colors = (style or {}).get("colors") or NATURE_COLORS_FALLBACK
285
+ accent = SEMANTIC_PALETTE["ACCENT"]
286
+ title = str(spec.get("title") or spec.get("name") or f"chart_{idx}")
287
+ xlabel, ylabel = display_name(x), display_name(y)
288
+ spec["axis_labels"] = {"x": xlabel, "y": ylabel}
289
+ figures = out_dir / "figures"
290
+ figures.mkdir(parents=True, exist_ok=True)
291
+ name = _safe_filename(spec.get("name") or spec.get("title") or f"chart_{idx}")
292
+ png = figures / f"{idx:02d}_{name}.png"
293
+
294
+ form = kind
295
+ if kind == "bar" and not x_is_num:
296
+ if _is_composition(spec, xs_raw, y, ys):
297
+ form = "composition"
298
+ elif 2 <= len(xs_raw) <= _DOT_MAX_GROUPS and str(y).lower() not in _COUNT_COLUMNS:
299
+ form = "dot_ci"
300
+ if kind == "bar" and form == "bar" and not _count_like(y, ys):
301
+ # Chart-type policy enforcement (eval-chart-style): a bar may only
302
+ # encode raw counts. A rate/mean/effect bar that is neither a class
303
+ # composition nor a <= 3-group comparison is demoted to the
304
+ # policy-compliant kind — line for binned rates, forest (horizontal
305
+ # dot plot) for ranked effects — so the rule holds even when the
306
+ # agent ignores it.
307
+ kind = _demote_bar(spec, y, ys)
308
+ form = kind
309
+ spec["kind"] = kind
310
+ spec["render_note"] = (
311
+ f"kind=bar demoted to {kind}: y column {str(y)!r} is not a count "
312
+ "(bars are for counts only — eval-chart-style policy)"
313
+ )
314
+ logger.warning(
315
+ "render_chart_specs: %r demoted bar -> %s (%r is not a count)",
316
+ spec.get("name"), kind, y,
317
+ )
318
+
319
+ # Apply the nature-figure style in a scoped rc_context (no global leak; fully
320
+ # deterministic -> same spec + CSV yields byte-identical PNGs).
321
+ with plt.rc_context(rc):
322
+ if form == "composition":
323
+ fig, ax = plt.subplots(figsize=(6.4, 1.7))
324
+ _draw_composition(ax, xs_raw, ys, colors)
325
+ ax.set_title(title, fontweight="bold", loc="left")
326
+ elif form == "dot_ci":
327
+ fig, ax = plt.subplots(figsize=(6.4, 1.6 + 0.55 * len(xs_raw)))
328
+ form = _draw_dot_ci(ax, rows, xs_raw, ys, ns, scale, accent)
329
+ ax.set_xlabel(ylabel)
330
+ ax.set_title(title, fontweight="bold", loc="left")
331
+ elif kind == "forest":
332
+ # Horizontal dot plot for ranked effects / means: position encodes
333
+ # the value, no filled area faking "accumulated amount". Sorted so
334
+ # the strongest is on top.
335
+ fig, ax = plt.subplots(figsize=(6.4, 1.6 + 0.45 * len(xs_raw)))
336
+ order = sorted(range(len(ys)), key=lambda i: ys[i])
337
+ labels = [str(xs_raw[i]) for i in order]
338
+ vals = [ys[i] for i in order]
339
+ ypos = list(range(len(vals)))
340
+ ax.hlines(ypos, [min(0.0, v) for v in vals], vals,
341
+ color=SEMANTIC_PALETTE["GRID"], linewidth=1.8, zorder=2)
342
+ ax.axvline(0, color=SEMANTIC_PALETTE["AXIS"], linewidth=0.9, zorder=1)
343
+ pt_colors = [outcome_color(lab) or accent for lab in labels]
344
+ ax.scatter(vals, ypos, s=68, c=pt_colors, edgecolor="white",
345
+ linewidth=0.8, zorder=3)
346
+ has_neg = any(v < 0 for v in vals)
347
+ for yi, v in zip(ypos, vals):
348
+ ax.annotate(f"{v:+.2f}" if has_neg else f"{v:.2f}", (v, yi),
349
+ textcoords="offset points", xytext=(7, -3.5),
350
+ fontsize=8.5, color=SEMANTIC_PALETTE["TEXT"])
351
+ ax.set_yticks(ypos)
352
+ ax.set_yticklabels(labels)
353
+ ax.grid(axis="x", linewidth=0.6, alpha=0.25, zorder=0)
354
+ ax.set_axisbelow(True)
355
+ # The value axis is horizontal: swap the axis labels.
356
+ ax.set_xlabel(ylabel)
357
+ ax.set_ylabel(xlabel)
358
+ ax.set_title(title, fontweight="bold", loc="left")
359
+ else:
360
+ fig, ax = plt.subplots(figsize=(6.4, 4.0))
361
+ if kind == "scatter":
362
+ ax.scatter(xs_num if x_is_num else range(len(xs_raw)), ys, s=26,
363
+ color=accent, edgecolor="white", linewidth=0.4, zorder=3)
364
+ if not x_is_num:
365
+ ax.set_xticks(range(len(xs_raw)))
366
+ ax.set_xticklabels([str(v) for v in xs_raw], rotation=45, ha="right")
367
+ elif kind in {"line", "timeseries"}:
368
+ px = xs_num if x_is_num else list(range(len(xs_raw)))
369
+ ax.plot(px, ys, marker="o", color=accent, linewidth=1.8, markersize=5, zorder=3)
370
+ if not x_is_num:
371
+ ax.set_xticks(range(len(xs_raw)))
372
+ ax.set_xticklabels([str(v) for v in xs_raw], rotation=45, ha="right")
373
+ if ns is not None:
374
+ for xv, yv, nv in zip(px, ys, ns):
375
+ ax.annotate(f"n={nv:g}", (xv, yv), textcoords="offset points",
376
+ xytext=(0, 7), ha="center", fontsize=7,
377
+ color=SEMANTIC_PALETTE["AXIS"])
378
+ else: # bar (counts, or more than _DOT_MAX_GROUPS groups)
379
+ positions = list(range(len(xs_raw)))
380
+ bar_colors = [outcome_color(v) or accent for v in xs_raw]
381
+ ax.bar(positions, ys, color=bar_colors, width=0.72, zorder=3)
382
+ ax.set_xticks(positions)
383
+ ax.set_xticklabels([str(v) for v in xs_raw], rotation=45, ha="right")
384
+ is_count = str(y).lower() in _COUNT_COLUMNS
385
+ for pos, yv, nv in zip(positions, ys, ns or [None] * len(ys)):
386
+ label = (f"{yv:g}" if is_count else (f"n={nv:g}" if nv is not None else ""))
387
+ if label:
388
+ ax.annotate(label, (pos, yv), textcoords="offset points",
389
+ xytext=(0, 3), ha="center", fontsize=7,
390
+ color=SEMANTIC_PALETTE["AXIS"])
391
+ if kind in {"bar", "line", "timeseries"}:
392
+ ax.grid(axis="y", linewidth=0.6, alpha=0.25, zorder=0)
393
+ ax.set_axisbelow(True)
394
+ if rate and kind != "scatter":
395
+ from matplotlib.ticker import PercentFormatter
396
+ ax.yaxis.set_major_formatter(PercentFormatter(scale, decimals=0))
397
+ ax.set_xlabel(xlabel)
398
+ ax.set_ylabel(ylabel)
399
+ ax.set_title(title, fontweight="bold")
400
+ fig.tight_layout()
401
+ fig.savefig(png, dpi=160, metadata={"Software": "evalrx", "Creation Time": None})
402
+ plt.close(fig)
403
+ spec["rendered_as"] = form
404
+ return png
405
+
406
+
407
+ # -- chart-form helpers -------------------------------------------------------
408
+
409
+ def _n_values(rows, y) -> "list[float] | None":
410
+ """Per-row sample size from an ``n``-like column (not the y column itself)."""
411
+ cols = rows[0].keys() if rows else []
412
+ for cand in _N_COLUMNS:
413
+ if cand in cols and cand != y:
414
+ vals = [_to_float(r.get(cand, "")) for r in rows]
415
+ if all(v is not None for v in vals):
416
+ return vals
417
+ return None
418
+
419
+
420
+ def _ci_values(rows) -> "tuple[list[float], list[float]] | None":
421
+ cols = rows[0].keys() if rows else []
422
+ for lo, hi in _CI_COLUMNS:
423
+ if lo in cols and hi in cols:
424
+ los = [_to_float(r.get(lo, "")) for r in rows]
425
+ his = [_to_float(r.get(hi, "")) for r in rows]
426
+ if all(v is not None for v in los + his):
427
+ return los, his
428
+ return None
429
+
430
+
431
+ def _rate_scale(y, ys) -> "float | None":
432
+ """1.0 when *y* is a fraction-valued rate column, 100.0 when it is a
433
+ percent-valued one, ``None`` when it is not a rate (counts, means of
434
+ unbounded quantities, effect sizes)."""
435
+ name = str(y).lower()
436
+ if name in _COUNT_COLUMNS or not any(w in name for w in _RATE_WORDS):
437
+ return None
438
+ if all(0.0 <= v <= 1.0 for v in ys):
439
+ return 1.0
440
+ if all(0.0 <= v <= 100.0 for v in ys) and ("pct" in name or "percent" in name):
441
+ return 100.0
442
+ return None
443
+
444
+
445
+ def _numerator_values(rows, y, ns) -> "list[float] | None":
446
+ """The successes column behind a rate (``n_fail``, ``n_with_audit``, ``k``,
447
+ ...): any ``n_<something>`` / ``k`` / ``successes`` column whose values never
448
+ exceed ``n``. Lets the host form a Wilson interval from the data instead of
449
+ guessing one."""
450
+ if ns is None or not rows:
451
+ return None
452
+ for col in rows[0].keys():
453
+ low = col.lower()
454
+ if col == y or low in _N_COLUMNS or low in _COUNT_COLUMNS:
455
+ continue
456
+ if not (low.startswith("n_") or low in ("k", "successes", "hits", "events")):
457
+ continue
458
+ vals = [_to_float(r.get(col, "")) for r in rows]
459
+ if all(v is not None and 0 <= v <= n for v, n in zip(vals, ns)):
460
+ return vals
461
+ return None
462
+
463
+
464
+ def _is_composition(spec, xs_raw, y, ys) -> bool:
465
+ """Class balance: a count per class over a handful of classes."""
466
+ if str(y).lower() not in _COUNT_COLUMNS or not (2 <= len(xs_raw) <= 6):
467
+ return False
468
+ if any(v < 0 for v in ys) or sum(ys) <= 0:
469
+ return False
470
+ text = f"{spec.get('name', '')} {spec.get('title', '')}".lower()
471
+ outcome_axis = all(outcome_color(v) is not None for v in xs_raw)
472
+ return outcome_axis or "balance" in text or "composition" in text
473
+
474
+
475
+ def _wilson(k: float, n: float, z: float = 1.959964) -> "tuple[float, float]":
476
+ """Wilson score interval for k successes in n trials."""
477
+ if n <= 0:
478
+ return 0.0, 1.0
479
+ p = k / n
480
+ denom = 1 + z * z / n
481
+ centre = (p + z * z / (2 * n)) / denom
482
+ half = z * ((p * (1 - p) / n + z * z / (4 * n * n)) ** 0.5) / denom
483
+ return max(0.0, centre - half), min(1.0, centre + half)
484
+
485
+
486
+ def _draw_composition(ax, labels, values, colors) -> None:
487
+ total = float(sum(values)) or 1.0
488
+ left = 0.0
489
+ for i, (lab, val) in enumerate(zip(labels, values)):
490
+ color = outcome_color(lab) or colors[i % len(colors)]
491
+ ax.barh([0], [val], left=left, color=color, height=0.6, zorder=3)
492
+ frac = val / total
493
+ text = f"{lab} {val:g} ({round(frac * 100)}%)"
494
+ if frac >= 0.14:
495
+ ax.text(left + val / 2, 0, text, ha="center", va="center",
496
+ color="white", fontsize=8.5, fontweight="bold")
497
+ else:
498
+ ax.text(left + val / 2, 0.42, text, ha="center", va="bottom",
499
+ color=SEMANTIC_PALETTE["TEXT"], fontsize=7.5)
500
+ left += val
501
+ ax.set_xlim(0, total)
502
+ ax.set_ylim(-0.6, 0.9)
503
+ ax.axis("off")
504
+
505
+
506
+ def _draw_dot_ci(ax, rows, labels, values, ns, scale, accent) -> str:
507
+ """Horizontal dot (+ 95% CI when one can be formed) per group; stem from 0.
508
+
509
+ The interval comes from the CSV's own ``ci_low``/``ci_high`` when present,
510
+ else — for a RATE with a numerator column (``n_fail``, ``n_with_audit``,
511
+ ``k`` ...) and ``n`` — from a Wilson score interval on those counts. A
512
+ ``mean_*`` of per-case values is not a binomial proportion, so no interval
513
+ is invented for it: the dot stands alone (lollipop) with its n. Returns the
514
+ form actually drawn: ``"dot_ci"`` or ``"lollipop"``."""
515
+ rate = scale is not None
516
+ ci = _ci_values(rows)
517
+ if ci is None and rate:
518
+ ks = _numerator_values(rows, None, ns)
519
+ if ks is not None:
520
+ los, his = [], []
521
+ for k, n in zip(ks, ns):
522
+ lo, hi = _wilson(k, n)
523
+ los.append(lo * scale)
524
+ his.append(hi * scale)
525
+ ci = (los, his)
526
+ form = "dot_ci" if ci is not None else "lollipop"
527
+ ypos = list(range(len(labels)))[::-1] # first CSV row on top
528
+ for yp, lab, val in zip(ypos, labels, values):
529
+ color = outcome_color(lab) or accent
530
+ ax.plot([0, val], [yp, yp], color=SEMANTIC_PALETTE["GRID"], linewidth=2.2, zorder=2)
531
+ if ci is not None:
532
+ i = labels.index(lab)
533
+ ax.plot([ci[0][i], ci[1][i]], [yp, yp], color=color, linewidth=1.6, zorder=3,
534
+ solid_capstyle="butt")
535
+ ax.plot([ci[0][i], ci[0][i]], [yp - 0.12, yp + 0.12], color=color, linewidth=1.2)
536
+ ax.plot([ci[1][i], ci[1][i]], [yp - 0.12, yp + 0.12], color=color, linewidth=1.2)
537
+ ax.scatter([val], [yp], s=60, color=color, edgecolor="white", linewidth=0.8, zorder=4)
538
+ shown = f"{round(val * 100 / scale)}%" if rate else f"{val:.2f}"
539
+ if ns is not None:
540
+ shown += f" (n={ns[labels.index(lab)]:g})"
541
+ ax.annotate(shown, (val, yp), textcoords="offset points", xytext=(0, 9),
542
+ ha="center", fontsize=7.5, color=SEMANTIC_PALETTE["TEXT"])
543
+ ax.set_yticks(ypos)
544
+ ax.set_yticklabels([str(v) for v in labels])
545
+ ax.set_ylim(-0.7, len(labels) - 0.3)
546
+ hi = max([v for v in values] + (list(ci[1]) if ci is not None else []))
547
+ lo = min([0.0] + [v for v in values] + (list(ci[0]) if ci is not None else []))
548
+ span = (hi - lo) or 1.0
549
+ ax.set_xlim(min(0.0, lo - 0.05 * span), hi + 0.18 * span)
550
+ ax.grid(axis="x", linewidth=0.6, alpha=0.25, zorder=0)
551
+ ax.set_axisbelow(True)
552
+ ax.spines["left"].set_visible(False)
553
+ ax.tick_params(axis="y", length=0)
554
+ if rate:
555
+ from matplotlib.ticker import PercentFormatter
556
+ ax.xaxis.set_major_formatter(PercentFormatter(scale, decimals=0))
557
+ return form
558
+
559
+
560
+ def _safe_filename(name: Any) -> str:
561
+ text = "".join(ch if (ch.isalnum() or ch in "-_") else "_" for ch in str(name)).strip("_")
562
+ return (text or "chart")[:48]
563
+
564
+
565
+ _FORM_WORDS = {
566
+ "composition": "composition strip of",
567
+ "dot_ci": "dot + 95% CI of",
568
+ "lollipop": "dot (lollipop) of",
569
+ "forest": "forest plot (ranked dots) of",
570
+ }
571
+
572
+
573
+ def _describe(spec: dict[str, Any], rows, x, y) -> str:
574
+ """One-line textual summary of a chart, used when the image can't render
575
+ and as the caption M3 sees alongside the attached PNG. Names the FORM the
576
+ host actually drew when that is known (``rendered_as``)."""
577
+ existing = spec.get("description")
578
+ if isinstance(existing, str) and existing.strip():
579
+ return existing.strip()
580
+ kind = str(spec.get("kind", "bar")).lower()
581
+ form = _FORM_WORDS.get(str(spec.get("rendered_as", "")), f"{kind} of")
582
+ title = str(spec.get("title") or spec.get("name") or "chart")
583
+ if x and y:
584
+ body = f"{form} {y} by {x}"
585
+ elif x:
586
+ body = f"{kind} over {x}"
587
+ else:
588
+ body = f"{kind} chart"
589
+ n = len(rows) if rows else 0
590
+ return f"{title}: {body}" + (f" ({n} rows)" if n else "")
evalrx/viz/schema.py ADDED
@@ -0,0 +1,36 @@
1
+ """Typed contracts for EvalRX exploratory visualization artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Literal, TypedDict
6
+
7
+ ChartKind = Literal["bar", "line", "scatter", "timeseries"]
8
+
9
+
10
+ class ChartSpec(TypedDict, total=False):
11
+ name: str
12
+ kind: ChartKind
13
+ data: str | list[dict[str, Any]]
14
+ x: str
15
+ y: str
16
+ title: str
17
+ description: str
18
+ figure_path: str
19
+ render_skipped: str
20
+
21
+
22
+ class VisualPlan(TypedDict, total=False):
23
+ name: str
24
+ question: str
25
+ data_shape: str
26
+ plot_kind: str
27
+ fallback_kind: ChartKind
28
+ required_columns: list[str]
29
+ rationale: str
30
+
31
+
32
+ class ChartReading(TypedDict, total=False):
33
+ chart: str
34
+ reading: str
35
+ do_not_infer: str
36
+