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,193 @@
1
+ """Trajectory → flat records — the M1→M2 bridge for agent runs.
2
+
3
+ `evalrx explore` and :func:`~evalrx.analysis.stats_tools.build_stats_input_from_records`
4
+ consume flat rows (``case_id`` + ``label`` + numeric signal columns). This
5
+ module flattens each agent :class:`~evalrx.core.case.Trajectory` into such
6
+ a row of trajectory-level features, so agent runs plug into the SAME M2/M3
7
+ statistical machinery as single-step probes.
8
+
9
+ Feature semantics (all deterministic, recomputed from steps when metrics are
10
+ missing):
11
+
12
+ ``n_steps`` / ``n_turns`` / ``n_tool_calls``
13
+ loop volume — how much work the agent did.
14
+ ``n_calls_<tool>``
15
+ per-tool call counts (tool-selection profile), one column per tool seen
16
+ anywhere in the batch (0-filled elsewhere).
17
+ ``n_tool_errors`` / ``tool_error_rate``
18
+ observations matching the executor's ``"[tool error"`` / ``"[error"``
19
+ envelope.
20
+ ``n_repeated_calls`` / ``repeated_call_frac`` / ``max_consecutive_repeat``
21
+ duplicate ``(tool, args)`` signatures — the loop-failure family
22
+ (``max_consecutive_repeat >= 2`` is what LoopDetector flags).
23
+ ``n_images_returned``
24
+ images tools handed back (visual-evidence volume).
25
+ ``first_call_turn``
26
+ turn of the first tool call (0 = never called a tool).
27
+ ``distinct_tools``, ``terminated_final``, ``answer_len_chars``
28
+ coverage, clean termination (1/0), and answer volume.
29
+
30
+ No imports from ``eval_agent`` (the analysis layer stays standalone).
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ from typing import Any, Iterable, Optional
37
+
38
+ from evalrx.core.case import FailureCase, Label, StepRole, Trajectory
39
+
40
+ _ERROR_MARKERS = ("[tool error", "[error")
41
+
42
+ #: The standard M2 question for agent-trajectory records. It declares the
43
+ #: column-family semantics up front so the analysis treats each family for
44
+ #: what it is (causal attribution vs stability vs judge labels vs cost) and
45
+ #: steers explanations toward the black-box fix surface (prompt/tool changes).
46
+ #: Use as-is or as a skeleton; the workbench and docs reference it.
47
+ AGENT_QUESTION_TEMPLATE = (
48
+ "What predicts failures (label=fail) in this agent run? Records are one row "
49
+ "per case with trajectory features: tool-use volume (n_tool_calls, "
50
+ "n_calls_<tool>), loop structure (max_consecutive_repeat, "
51
+ "repeated_call_frac), tool errors (tool_error_rate), cost "
52
+ "(total_completion_tokens, total_latency_ms), stability from repeated runs "
53
+ "(success_rate, flaky, pass_at_k vs pass_all_k), causal tool attribution "
54
+ "from subset ablation (shap_outcome_<tool>, no_tools_pass), and a "
55
+ "judge-assigned failure_mode (a hypothesis label, not ground truth). "
56
+ "Compare FAIL vs PASS, say which signal families carry independent "
57
+ "information, and prefer explanations that point at fixable causes — "
58
+ "prompt wording, tool descriptions, tool availability, or loop policy."
59
+ )
60
+
61
+
62
+ def _observation_text(observation: Any) -> str:
63
+ if isinstance(observation, dict):
64
+ return str(observation.get("text", ""))
65
+ return "" if observation is None else str(observation)
66
+
67
+
68
+ def _call_signature(tool_call: dict) -> "tuple[str, str]":
69
+ name = str(tool_call.get("name", ""))
70
+ try:
71
+ args = json.dumps(tool_call.get("args", {}), sort_keys=True, default=str)
72
+ except (TypeError, ValueError):
73
+ args = str(tool_call.get("args"))
74
+ return name, args
75
+
76
+
77
+ def trajectory_features(trajectory: Trajectory) -> dict[str, Any]:
78
+ """Flatten one trajectory into a dict of scalar features (see module doc)."""
79
+ tool_calls: list[tuple[str, str]] = []
80
+ n_tool_errors = 0
81
+ n_images = 0
82
+ first_call_turn = 0
83
+ max_consecutive = 0
84
+ consecutive = 0
85
+ prev_sig: Optional[tuple[str, str]] = None
86
+
87
+ for step in trajectory.steps:
88
+ if step.tool_call:
89
+ sig = _call_signature(step.tool_call)
90
+ tool_calls.append(sig)
91
+ if first_call_turn == 0:
92
+ first_call_turn = int(step.span.get("turn", len(tool_calls)))
93
+ if sig == prev_sig:
94
+ consecutive += 1
95
+ else:
96
+ consecutive = 1
97
+ prev_sig = sig
98
+ max_consecutive = max(max_consecutive, consecutive)
99
+ if step.role is StepRole.TOOL:
100
+ text = _observation_text(step.observation)
101
+ if text.startswith(_ERROR_MARKERS):
102
+ n_tool_errors += 1
103
+ if isinstance(step.observation, dict):
104
+ n_images += int(step.observation.get("n_images") or 0)
105
+
106
+ n_calls = len(tool_calls)
107
+ seen: set[tuple[str, str]] = set()
108
+ n_repeated = 0
109
+ for sig in tool_calls:
110
+ if sig in seen:
111
+ n_repeated += 1
112
+ seen.add(sig)
113
+
114
+ metrics = trajectory.metrics or {}
115
+ actor_spans = [s.span for s in trajectory.steps if s.role is StepRole.ACTOR and s.span]
116
+ latencies = [float(sp["latency_ms"]) for sp in actor_spans if sp.get("latency_ms")]
117
+ total_latency = sum(latencies)
118
+ total_prompt_toks = sum(int(sp.get("prompt_tokens") or 0) for sp in actor_spans)
119
+ total_completion_toks = sum(int(sp.get("completion_tokens") or 0) for sp in actor_spans)
120
+
121
+ features: dict[str, Any] = {
122
+ "n_steps": int(metrics.get("n_steps", len(trajectory.steps))),
123
+ "n_turns": int(metrics.get("n_turns", 0)) or sum(
124
+ 1 for s in trajectory.steps if s.role is StepRole.ACTOR
125
+ ),
126
+ "n_tool_calls": n_calls,
127
+ "distinct_tools": len({name for name, _ in tool_calls}),
128
+ "n_tool_errors": n_tool_errors,
129
+ "tool_error_rate": round(n_tool_errors / n_calls, 4) if n_calls else 0.0,
130
+ "n_repeated_calls": n_repeated,
131
+ "repeated_call_frac": round(n_repeated / n_calls, 4) if n_calls else 0.0,
132
+ "max_consecutive_repeat": max_consecutive,
133
+ "n_images_returned": n_images,
134
+ "first_call_turn": first_call_turn,
135
+ "terminated": str(metrics.get("terminated", "unknown")),
136
+ "terminated_final": 1 if metrics.get("terminated") == "final" else 0,
137
+ "answer_len_chars": len(str(trajectory.final_answer)) if trajectory.final_answer else 0,
138
+ # cost accounting (0 when the backend reports no usage/latency)
139
+ "total_latency_ms": round(float(metrics.get("total_latency_ms") or total_latency), 1),
140
+ "mean_turn_latency_ms": round(total_latency / len(latencies), 1) if latencies else 0.0,
141
+ "total_prompt_tokens": int(metrics.get("total_prompt_tokens") or total_prompt_toks),
142
+ "total_completion_tokens": int(
143
+ metrics.get("total_completion_tokens") or total_completion_toks
144
+ ),
145
+ }
146
+ for name in sorted({name for name, _ in tool_calls}):
147
+ features[f"n_calls_{name}"] = sum(1 for n, _ in tool_calls if n == name)
148
+ return features
149
+
150
+
151
+ def trajectories_to_records(
152
+ items: Iterable["FailureCase | Trajectory"],
153
+ *,
154
+ id_col: str = "case_id",
155
+ label_col: str = "label",
156
+ ) -> list[dict[str, Any]]:
157
+ """Flatten cases/trajectories into ``records.json``-shaped rows.
158
+
159
+ Each row carries *id_col*, *label_col* (the case label, falling back to
160
+ the trajectory outcome), the trajectory features, and any scalar
161
+ ``case.metadata`` entries (provenance columns like model/source). Per-tool
162
+ count columns are 0-filled across the batch so every row has the same
163
+ schema. Items without a trajectory are skipped.
164
+ """
165
+ rows: list[dict[str, Any]] = []
166
+ tool_cols: set[str] = set()
167
+ for item in items:
168
+ if isinstance(item, Trajectory):
169
+ case, trajectory = None, item
170
+ else:
171
+ case, trajectory = item, item.trajectory
172
+ if trajectory is None:
173
+ continue
174
+ label = trajectory.outcome
175
+ if case is not None and case.label is not Label.UNKNOWN:
176
+ label = case.label
177
+ row: dict[str, Any] = {
178
+ id_col: case.id if case is not None else trajectory.sample_id,
179
+ label_col: label.value if isinstance(label, Label) else str(label),
180
+ }
181
+ if case is not None:
182
+ for key, value in (case.metadata or {}).items():
183
+ if isinstance(value, (str, int, float, bool)) and key not in (id_col, label_col):
184
+ row.setdefault(key, value)
185
+ features = trajectory_features(trajectory)
186
+ tool_cols.update(k for k in features if k.startswith("n_calls_"))
187
+ row.update(features)
188
+ rows.append(row)
189
+
190
+ for row in rows: # uniform schema: 0-fill per-tool columns
191
+ for col in tool_cols:
192
+ row.setdefault(col, 0)
193
+ return rows
@@ -0,0 +1,431 @@
1
+ """Durable primitives for the browser workbench.
2
+
3
+ The original upload app treated an archive as a directory of JSON files. This
4
+ module is deliberately independent from Streamlit: it validates and extracts
5
+ an archive, turns the supported tabular and media files into an auditable
6
+ ``DatasetBundle``, and writes append-only events/messages for a data thread.
7
+ Keeping these operations here makes the browser UI a thin observer rather than
8
+ the source of truth for an analysis run.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import csv
14
+ import hashlib
15
+ import io
16
+ import json
17
+ import mimetypes
18
+ import os
19
+ import shutil
20
+ import subprocess
21
+ import threading
22
+ import zipfile
23
+ from dataclasses import asdict, dataclass, field
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+ from typing import Any, Iterable
27
+
28
+ TABULAR_SUFFIXES = {".json", ".jsonl", ".ndjson", ".csv", ".tsv", ".parquet", ".xlsx", ".xls"}
29
+ IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tif", ".tiff"}
30
+ PDF_SUFFIXES = {".pdf"}
31
+ AUDIO_SUFFIXES = {".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg"}
32
+ VIDEO_SUFFIXES = {".mp4", ".mov", ".webm", ".mkv", ".avi"}
33
+ SUPPORTED_SUFFIXES = TABULAR_SUFFIXES | IMAGE_SUFFIXES | PDF_SUFFIXES | AUDIO_SUFFIXES | VIDEO_SUFFIXES
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class UploadLimits:
38
+ """Conservative local-workbench limits; callers may explicitly raise them."""
39
+
40
+ max_archive_bytes: int = 1024 * 1024 * 1024
41
+ max_extracted_bytes: int = 4 * 1024 * 1024 * 1024
42
+ max_files: int = 10_000
43
+ max_compression_ratio: float = 100.0
44
+ max_path_depth: int = 32
45
+
46
+
47
+ @dataclass
48
+ class Event:
49
+ seq: int
50
+ timestamp: str
51
+ thread_id: str
52
+ turn_id: str
53
+ stage: str
54
+ status: str
55
+ message: str
56
+ attempt: int | None = None
57
+ artifact_refs: list[str] = field(default_factory=list)
58
+ metrics: dict[str, Any] = field(default_factory=dict)
59
+
60
+ def to_dict(self) -> dict[str, Any]:
61
+ return asdict(self)
62
+
63
+
64
+ class EventSink:
65
+ """Append-only, restart-safe progress events for one data thread."""
66
+
67
+ def __init__(self, path: str | Path, *, thread_id: str, turn_id: str) -> None:
68
+ self.path = Path(path)
69
+ self.thread_id = thread_id
70
+ self.turn_id = turn_id
71
+ self.path.parent.mkdir(parents=True, exist_ok=True)
72
+ self._lock = threading.Lock()
73
+ self._seq = _last_event_seq(self.path)
74
+
75
+ def emit(
76
+ self,
77
+ stage: str,
78
+ status: str,
79
+ message: str,
80
+ *,
81
+ attempt: int | None = None,
82
+ artifact_refs: Iterable[str | Path] = (),
83
+ metrics: dict[str, Any] | None = None,
84
+ ) -> Event:
85
+ with self._lock:
86
+ self._seq += 1
87
+ event = Event(
88
+ seq=self._seq,
89
+ timestamp=datetime.now(timezone.utc).isoformat(timespec="seconds"),
90
+ thread_id=self.thread_id,
91
+ turn_id=self.turn_id,
92
+ stage=stage,
93
+ status=status,
94
+ message=message,
95
+ attempt=attempt,
96
+ artifact_refs=[str(p) for p in artifact_refs],
97
+ metrics=metrics or {},
98
+ )
99
+ _append_jsonl(self.path, event.to_dict())
100
+ return event
101
+
102
+
103
+ @dataclass
104
+ class DatasetBundle:
105
+ """Normalized view of a local archive plus references to original media."""
106
+
107
+ root: str
108
+ source_dir: str
109
+ records_path: str
110
+ media_units_path: str
111
+ manifest_path: str
112
+ n_records: int
113
+ n_media_units: int
114
+ files: list[dict[str, Any]]
115
+ warnings: list[str] = field(default_factory=list)
116
+
117
+ def to_dict(self) -> dict[str, Any]:
118
+ return asdict(self)
119
+
120
+
121
+ class ThreadStore:
122
+ """Small filesystem store for a local, single-user analysis conversation."""
123
+
124
+ def __init__(self, root: str | Path) -> None:
125
+ self.root = Path(root)
126
+
127
+ def create(self, *, name: str, provider: str, model: str = "") -> Path:
128
+ slug = _safe_name(name)
129
+ thread_id = f"{slug}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
130
+ thread = self.root / thread_id
131
+ thread.mkdir(parents=True, exist_ok=False)
132
+ meta = {
133
+ "id": thread_id,
134
+ "name": name,
135
+ "provider": provider,
136
+ "model": model,
137
+ "created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
138
+ "current_turn": "",
139
+ }
140
+ (thread / "thread.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
141
+ return thread
142
+
143
+ @staticmethod
144
+ def append_message(thread: str | Path, role: str, content: str, *, turn_id: str = "") -> None:
145
+ _append_jsonl(Path(thread) / "messages.jsonl", {
146
+ "timestamp": datetime.now(timezone.utc).isoformat(timespec="seconds"),
147
+ "role": role,
148
+ "content": content,
149
+ "turn_id": turn_id,
150
+ })
151
+
152
+ @staticmethod
153
+ def set_current_turn(thread: str | Path, turn_id: str) -> None:
154
+ path = Path(thread) / "thread.json"
155
+ try:
156
+ data = json.loads(path.read_text(encoding="utf-8"))
157
+ except Exception:
158
+ data = {}
159
+ data["current_turn"] = turn_id
160
+ path.write_text(json.dumps(data, indent=2), encoding="utf-8")
161
+
162
+
163
+ def extract_archive(payload: bytes, destination: str | Path, *, limits: UploadLimits | None = None) -> Path:
164
+ """Safely extract a ZIP and return its data root.
165
+
166
+ In addition to zip-slip, this rejects symlinks, duplicate targets, overly
167
+ deep paths, excessive file counts, and archives with suspicious expansion.
168
+ """
169
+ limits = limits or UploadLimits()
170
+ if len(payload) > limits.max_archive_bytes:
171
+ raise ValueError("archive exceeds the configured upload size limit")
172
+ destination = Path(destination)
173
+ destination.mkdir(parents=True, exist_ok=True)
174
+ root = destination.resolve()
175
+ seen: set[Path] = set()
176
+ total = 0
177
+ n_files = 0
178
+ with zipfile.ZipFile(io.BytesIO(payload)) as zf:
179
+ infos = [i for i in zf.infolist() if not i.is_dir()]
180
+ if len(infos) > limits.max_files:
181
+ raise ValueError("archive contains too many files")
182
+ for info in infos:
183
+ name = info.filename.replace("\\", "/")
184
+ parts = Path(name).parts
185
+ if "__MACOSX" in parts or parts[-1] in {".DS_Store", "Thumbs.db"}:
186
+ continue
187
+ is_link = (info.external_attr >> 16) & 0o170000 == 0o120000
188
+ if (not name or name.startswith("/") or ".." in parts or is_link
189
+ or len(parts) > limits.max_path_depth):
190
+ raise ValueError(f"unsafe path in archive: {info.filename!r}")
191
+ if info.file_size and info.compress_size and info.file_size / info.compress_size > limits.max_compression_ratio:
192
+ raise ValueError(f"archive member has an unsafe compression ratio: {info.filename!r}")
193
+ total += info.file_size
194
+ if total > limits.max_extracted_bytes:
195
+ raise ValueError("archive exceeds the configured extracted size limit")
196
+ target = (destination / name).resolve()
197
+ if not target.is_relative_to(root) or target in seen:
198
+ raise ValueError(f"unsafe or duplicate path in archive: {info.filename!r}")
199
+ seen.add(target)
200
+ target.parent.mkdir(parents=True, exist_ok=True)
201
+ with zf.open(info) as source, target.open("wb") as out:
202
+ shutil.copyfileobj(source, out)
203
+ n_files += 1
204
+ if not n_files:
205
+ raise ValueError("archive contains no files")
206
+ children = [p for p in destination.iterdir() if p.name not in {"__MACOSX", ".DS_Store"}]
207
+ return children[0] if len(children) == 1 and children[0].is_dir() else destination
208
+
209
+
210
+ def ingest_directory(
211
+ source_dir: str | Path,
212
+ bundle_dir: str | Path,
213
+ *,
214
+ media_unit_limit: int = 500,
215
+ sink: EventSink | None = None,
216
+ ) -> DatasetBundle:
217
+ """Create an auditable normalized bundle without sending data off-machine."""
218
+ source = Path(source_dir).resolve()
219
+ bundle = Path(bundle_dir).resolve()
220
+ bundle.mkdir(parents=True, exist_ok=True)
221
+ if sink:
222
+ sink.emit("discover", "started", "Discovering uploaded files")
223
+ files = [p for p in sorted(source.rglob("*")) if p.is_file() and not _is_junk(p)]
224
+ file_entries = [_file_entry(p, source) for p in files]
225
+ unsupported = [e["path"] for e in file_entries if e["suffix"] not in SUPPORTED_SUFFIXES]
226
+ if sink:
227
+ sink.emit("discover", "completed", f"Discovered {len(files)} files", metrics={"files": len(files)})
228
+ sink.emit("normalize", "started", "Normalizing tabular records and media metadata")
229
+
230
+ records: list[dict[str, Any]] = []
231
+ media: list[dict[str, Any]] = []
232
+ warnings: list[str] = []
233
+ for path, entry in zip(files, file_entries):
234
+ suffix = entry["suffix"]
235
+ if suffix in TABULAR_SUFFIXES:
236
+ try:
237
+ for row in _read_tabular(path):
238
+ row.setdefault("_source_file", entry["path"])
239
+ records.append(row)
240
+ except Exception as exc: # data quality is reported, not fatal for a mixed archive
241
+ warnings.append(f"could not read {entry['path']}: {exc}")
242
+ elif suffix in IMAGE_SUFFIXES | PDF_SUFFIXES | AUDIO_SUFFIXES | VIDEO_SUFFIXES:
243
+ bundled_media = _materialize_media(path, source, bundle)
244
+ entry["bundle_path"] = str(bundled_media.relative_to(bundle))
245
+ media.extend(_media_units(path, source, entry, media_unit_limit - len(media)))
246
+ if len(media) >= media_unit_limit:
247
+ warnings.append(f"media unit limit ({media_unit_limit}) reached; remaining media was not profiled")
248
+ break
249
+
250
+ records_path = bundle / "records.json"
251
+ media_path = bundle / "media_units.jsonl"
252
+ manifest_path = bundle / "bundle.json"
253
+ records_path.write_text(json.dumps(records, indent=2, default=str), encoding="utf-8")
254
+ for unit in media:
255
+ _append_jsonl(media_path, unit)
256
+ bundle_data = DatasetBundle(
257
+ root=str(bundle), source_dir=str(source), records_path=str(records_path),
258
+ media_units_path=str(media_path), manifest_path=str(manifest_path),
259
+ n_records=len(records), n_media_units=len(media), files=file_entries,
260
+ warnings=warnings + ([f"unsupported files skipped: {', '.join(unsupported[:8])}"] if unsupported else []),
261
+ )
262
+ manifest_path.write_text(json.dumps(bundle_data.to_dict(), indent=2), encoding="utf-8")
263
+ if sink:
264
+ sink.emit("normalize", "completed", "Dataset bundle is ready", artifact_refs=[manifest_path, records_path, media_path], metrics={"records": len(records), "media_units": len(media)})
265
+ return bundle_data
266
+
267
+
268
+ def load_bundle(path: str | Path) -> DatasetBundle:
269
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
270
+ return DatasetBundle(**data)
271
+
272
+
273
+ def _read_tabular(path: Path) -> list[dict[str, Any]]:
274
+ suffix = path.suffix.lower()
275
+ if suffix in {".json", ".jsonl", ".ndjson"}:
276
+ return _read_json_rows(path)
277
+ if suffix in {".csv", ".tsv"}:
278
+ with path.open("r", encoding="utf-8-sig", errors="replace", newline="") as f:
279
+ return [dict(row) for row in csv.DictReader(f, delimiter="\t" if suffix == ".tsv" else ",")]
280
+ try:
281
+ import pandas as pd
282
+ except ImportError as exc: # pragma: no cover - dashboard extra supplies pandas
283
+ raise RuntimeError("pandas is required to read Parquet and Excel files") from exc
284
+ frame = pd.read_parquet(path) if suffix == ".parquet" else pd.read_excel(path)
285
+ return [dict(r) for r in frame.to_dict(orient="records")]
286
+
287
+
288
+ def _read_json_rows(path: Path) -> list[dict[str, Any]]:
289
+ text = path.read_text(encoding="utf-8", errors="replace").strip()
290
+ if not text:
291
+ return []
292
+ try:
293
+ value = json.loads(text)
294
+ if isinstance(value, list):
295
+ return [dict(v) if isinstance(v, dict) else {"value": v} for v in value]
296
+ if isinstance(value, dict):
297
+ for key in ("cases", "records", "results", "rows", "items", "data", "examples", "samples"):
298
+ if isinstance(value.get(key), list):
299
+ meta = {k: v for k, v in value.items() if k != key and not isinstance(v, (list, dict))}
300
+ return [{**meta, **dict(v)} if isinstance(v, dict) else {**meta, "value": v} for v in value[key]]
301
+ return [value]
302
+ except json.JSONDecodeError:
303
+ pass
304
+ rows = []
305
+ for line in text.splitlines():
306
+ try:
307
+ value = json.loads(line)
308
+ except json.JSONDecodeError:
309
+ continue
310
+ rows.append(dict(value) if isinstance(value, dict) else {"value": value})
311
+ return rows
312
+
313
+
314
+ def _media_units(path: Path, source: Path, entry: dict[str, Any], remaining: int) -> list[dict[str, Any]]:
315
+ if remaining <= 0:
316
+ return []
317
+ parent_id = hashlib.sha256(entry["path"].encode()).hexdigest()[:16]
318
+ unit = {"id": parent_id, "parent_id": None, "kind": _media_kind(entry["suffix"]), **entry}
319
+ suffix = entry["suffix"]
320
+ if suffix in IMAGE_SUFFIXES:
321
+ try:
322
+ from PIL import Image
323
+ with Image.open(path) as image:
324
+ unit.update(width=image.width, height=image.height, mode=image.mode)
325
+ except Exception as exc:
326
+ unit["warning"] = f"image metadata unavailable: {exc}"
327
+ elif suffix in PDF_SUFFIXES:
328
+ return _pdf_units(path, unit, remaining)
329
+ elif suffix in AUDIO_SUFFIXES | VIDEO_SUFFIXES:
330
+ unit.update(_ffprobe_metadata(path))
331
+ duration = unit.get("duration_sec")
332
+ if isinstance(duration, (float, int)) and duration > 0 and remaining > 1:
333
+ segment_sec = 30.0 if suffix in AUDIO_SUFFIXES else 10.0
334
+ children = []
335
+ start = 0.0
336
+ while start < float(duration) and len(children) < remaining - 1:
337
+ end = min(start + segment_sec, float(duration))
338
+ children.append({
339
+ "id": f"{parent_id}:{len(children):04d}", "parent_id": parent_id,
340
+ "kind": f"{unit['kind']}_segment", **entry,
341
+ "start_sec": start, "end_sec": end,
342
+ })
343
+ start = end
344
+ return [unit, *children]
345
+ return [unit]
346
+
347
+
348
+ def _materialize_media(path: Path, source: Path, bundle: Path) -> Path:
349
+ """Expose original media to a coding-agent workdir without a fragile symlink."""
350
+ target = bundle / "media" / path.relative_to(source)
351
+ target.parent.mkdir(parents=True, exist_ok=True)
352
+ if target.exists():
353
+ return target
354
+ try:
355
+ os.link(path, target)
356
+ except OSError:
357
+ shutil.copy2(path, target)
358
+ return target
359
+
360
+
361
+ def _pdf_units(path: Path, unit: dict[str, Any], remaining: int) -> list[dict[str, Any]]:
362
+ try:
363
+ from pypdf import PdfReader
364
+ reader = PdfReader(str(path))
365
+ unit["pages"] = len(reader.pages)
366
+ children = []
367
+ for page_no, page in enumerate(reader.pages[:max(0, remaining - 1)], start=1):
368
+ text = page.extract_text() or ""
369
+ children.append({
370
+ "id": f"{unit['id']}:p{page_no}", "parent_id": unit["id"], "kind": "pdf_page",
371
+ **{k: v for k, v in unit.items() if k not in {"id", "parent_id", "kind"}},
372
+ "page": page_no, "text_excerpt": text[:1000],
373
+ })
374
+ return [unit, *children]
375
+ except Exception:
376
+ unit.update(pages=None, warning="PDF text extraction requires the optional pypdf dependency")
377
+ return [unit]
378
+
379
+
380
+ def _ffprobe_metadata(path: Path) -> dict[str, Any]:
381
+ try:
382
+ proc = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "json", str(path)], capture_output=True, text=True, timeout=15, check=False)
383
+ data = json.loads(proc.stdout or "{}")
384
+ duration = (data.get("format") or {}).get("duration")
385
+ return {"duration_sec": float(duration) if duration is not None else None}
386
+ except Exception:
387
+ return {"duration_sec": None, "warning": "ffprobe metadata unavailable"}
388
+
389
+
390
+ def _file_entry(path: Path, root: Path) -> dict[str, Any]:
391
+ suffix = path.suffix.lower()
392
+ return {"path": str(path.relative_to(root)), "suffix": suffix, "mime": mimetypes.guess_type(path.name)[0] or "application/octet-stream", "bytes": path.stat().st_size}
393
+
394
+
395
+ def _media_kind(suffix: str) -> str:
396
+ if suffix in IMAGE_SUFFIXES:
397
+ return "image"
398
+ if suffix in PDF_SUFFIXES:
399
+ return "pdf"
400
+ if suffix in AUDIO_SUFFIXES:
401
+ return "audio"
402
+ return "video"
403
+
404
+
405
+ def _is_junk(path: Path) -> bool:
406
+ return "__MACOSX" in path.parts or path.name in {".DS_Store", "Thumbs.db"}
407
+
408
+
409
+ def _safe_name(value: str) -> str:
410
+ clean = "".join(c if c.isalnum() or c in "_-" else "_" for c in value).strip("_")
411
+ return clean or "dataset"
412
+
413
+
414
+ def _append_jsonl(path: Path, value: dict[str, Any]) -> None:
415
+ path.parent.mkdir(parents=True, exist_ok=True)
416
+ with path.open("a", encoding="utf-8") as f:
417
+ f.write(json.dumps(value, default=str) + "\n")
418
+ f.flush()
419
+ os.fsync(f.fileno())
420
+
421
+
422
+ def _last_event_seq(path: Path) -> int:
423
+ if not path.exists():
424
+ return 0
425
+ try:
426
+ for line in reversed(path.read_text(encoding="utf-8").splitlines()):
427
+ if line.strip():
428
+ return int(json.loads(line).get("seq", 0))
429
+ except Exception:
430
+ pass
431
+ return 0
@@ -0,0 +1,42 @@
1
+ """Analyzers — functional taxonomy keyed by required capability (not black/white-box).
2
+
3
+ Every analyzer declares ``requires`` (capabilities) + ``applies_to_modalities`` and
4
+ self-registers on import. Categories:
5
+
6
+ perturbation/ input masking (RISE; VL-SHAP/MM-SHAP) — GENERATE/LOGPROBS
7
+ uncertainty/ entropy / self-consistency / verbalized conf — LOGITS/GENERATE
8
+ hallucination/ POPE / CHAIR / OPERA / VCD — GENERATE/ATTENTION
9
+ attention/ summary / rollout / sink / relative-attn — ATTENTION
10
+ attribution/ Grad-CAM / Chefer generic-attention — GRADIENTS
11
+ lens/ logit-lens / tuned-lens — HIDDEN_STATES
12
+ patching/ causal tracing / activation patching — HIDDEN_STATES (read+write)
13
+ geometry/ CKA / linear-probe — HIDDEN_STATES
14
+ reasoning/ extraction/termination hygiene; arithmetic,
15
+ self-repair, step-value, knowledge split — GENERATE
16
+ agent/ loop / ignored-obs / first-error / counterfactual — Trajectory
17
+
18
+ Import is torch-tolerant: the ``attention`` subpackage loads torch at import, so on
19
+ the light (pure-API) install it is skipped and its analyzers are simply not offered.
20
+ """
21
+
22
+ from evalrx.analyzers import ( # noqa: F401 -- import to self-register
23
+ agent,
24
+ attribution,
25
+ geometry,
26
+ hallucination,
27
+ lens,
28
+ patching,
29
+ perturbation,
30
+ reasoning,
31
+ uncertainty,
32
+ )
33
+ from evalrx.analyzers.base import Analyzer, Result
34
+
35
+ try: # attention/summary imports torch+numpy at module load
36
+ from evalrx.analyzers import attention # noqa: F401
37
+ except ModuleNotFoundError as e: # pragma: no cover - light install
38
+ if e.name not in {"torch", "numpy"}:
39
+ raise
40
+ attention = None # type: ignore
41
+
42
+ __all__ = ["Analyzer", "Result"]