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,607 @@
1
+ """Failure-mode clustering: group FAIL cases into interpretable clusters.
2
+
3
+ Complements the M2 exploratory agent's per-signal EDA with pattern discovery
4
+ over the raw failing cases themselves — the deterministic tier needs no LLM
5
+ and no required extra dependency (a pure-numpy fallback always works; install
6
+ the ``[cluster]`` extra for TF-IDF + hdbscan/Agglomerative clustering); an
7
+ optional LLM tier then names each cluster from its exemplars.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import re
14
+ import zlib
15
+ from collections import Counter
16
+ from dataclasses import dataclass, field
17
+ from typing import TYPE_CHECKING, Any, Callable
18
+
19
+ from evalrx.agent_runtime.json_shape import validate_json_shape
20
+
21
+ if TYPE_CHECKING:
22
+ from evalrx.core.model import Model
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ _DEFAULT_TEXT_COLS = ("prompt", "question", "input", "text", "output", "response", "observed")
27
+ _STOPWORDS = frozenset({
28
+ "the", "a", "an", "is", "was", "were", "are", "be", "been", "to", "of", "in",
29
+ "on", "for", "and", "or", "it", "this", "that", "with", "as", "at", "by",
30
+ "from", "not", "no", "does", "did", "do", "has", "have", "had",
31
+ })
32
+
33
+
34
+ @dataclass
35
+ class FailureMode:
36
+ """One cluster of FAIL cases."""
37
+
38
+ name: str
39
+ description: str
40
+ case_ids: list[str] = field(default_factory=list)
41
+ exemplars: list[dict[str, Any]] = field(default_factory=list)
42
+ size: int = 0
43
+ top_terms: list[str] = field(default_factory=list)
44
+ # Boundary-aware induction (opt-in via cluster_failures(boundary_aware=True)):
45
+ # each entry is {"fail": <row>, "nearest_pass": <row>, "similarity": float} —
46
+ # a peripheral failing case paired with its nearest verified non-failure, so
47
+ # an LLM can be shown *where* the failure boundary sits, not just what the
48
+ # failing cases have in common. Empty when boundary_aware is off.
49
+ boundary_pairs: list[dict[str, Any]] = field(default_factory=list)
50
+
51
+ def to_dict(self) -> dict[str, Any]:
52
+ return {
53
+ "name": self.name,
54
+ "description": self.description,
55
+ "case_ids": self.case_ids,
56
+ "exemplars": self.exemplars,
57
+ "size": self.size,
58
+ "top_terms": self.top_terms,
59
+ "boundary_pairs": self.boundary_pairs,
60
+ }
61
+
62
+
63
+ @dataclass
64
+ class FailureModeReport:
65
+ """Output of :func:`cluster_failures`."""
66
+
67
+ clusters: list[FailureMode] = field(default_factory=list)
68
+ n_fail_cases: int = 0
69
+ unclustered_ids: list[str] = field(default_factory=list)
70
+ method: str = "none"
71
+ named_by: str = "top_terms"
72
+ params: dict[str, Any] = field(default_factory=dict)
73
+
74
+ def to_dict(self) -> dict[str, Any]:
75
+ return {
76
+ "clusters": [c.to_dict() for c in self.clusters],
77
+ "n_fail_cases": self.n_fail_cases,
78
+ "unclustered_ids": self.unclustered_ids,
79
+ "method": self.method,
80
+ "named_by": self.named_by,
81
+ "params": self.params,
82
+ }
83
+
84
+ def as_hypothesis_context(self) -> str:
85
+ """Compact section for the M3 hypothesis-proposal prompt."""
86
+ if not self.clusters:
87
+ return ""
88
+ lines = [f"FAILURE MODES (clustered, method={self.method}):"]
89
+ for c in self.clusters:
90
+ lines.append(f" - {c.name} (n={c.size}): {c.description}")
91
+ return "\n".join(lines)
92
+
93
+
94
+ def _row_id(row: dict[str, Any], idx: int, id_key: str) -> str:
95
+ cid = row.get(id_key)
96
+ return str(cid) if cid not in (None, "") else f"row{idx}"
97
+
98
+
99
+ def _row_text(row: dict[str, Any], text_cols: "list[str] | None") -> str:
100
+ cols = text_cols or [c for c in _DEFAULT_TEXT_COLS if c in row]
101
+ parts = [str(row[c]) for c in cols if row.get(c) not in (None, "")]
102
+ return " ".join(parts)
103
+
104
+
105
+ def _row_signal_flags(row: dict[str, Any], signal_cols: "list[str] | None", exclude: set) -> str:
106
+ if signal_cols is not None:
107
+ cols = signal_cols
108
+ else:
109
+ cols = [
110
+ k for k, v in row.items()
111
+ if k not in exclude and isinstance(v, (int, float, bool))
112
+ ]
113
+ return " ".join(f"{k}={row[k]}" for k in cols if k in row)
114
+
115
+
116
+ _OUTPUT_COLS = ("output", "response", "observed")
117
+
118
+
119
+ def _row_output(row: dict[str, Any]) -> str:
120
+ for col in _OUTPUT_COLS:
121
+ val = row.get(col)
122
+ if val not in (None, ""):
123
+ return str(val)
124
+ return ""
125
+
126
+
127
+ def _error_signal_deterministic(row: dict[str, Any], expected_col: str) -> str:
128
+ """Failure-aware fallback with no LLM: a crude but always-available mismatch
129
+ signal ("expected X got Y"), so proximity in embedding space reflects *how*
130
+ the model failed a bit more than raw prompt text alone would."""
131
+ expected = row.get(expected_col)
132
+ if expected in (None, ""):
133
+ return ""
134
+ return f"expected={str(expected)[:80]} got={_row_output(row)[:80]}"
135
+
136
+
137
+ def _error_signals_llm(
138
+ fail_rows: list[tuple[int, dict[str, Any]]],
139
+ expected_col: str,
140
+ judge: "Model",
141
+ *,
142
+ max_repairs: int = 1,
143
+ ) -> "list[str] | None":
144
+ """One batched call describing the failure mechanism for every FAIL row.
145
+
146
+ Returns a list aligned 1:1 with *fail_rows*, or None if the judge never
147
+ produced a parseable response (caller falls back to the deterministic
148
+ signal in that case).
149
+ """
150
+ from evalrx.analysis.prompts.failure_modes import ERROR_SIGNAL_PROMPT
151
+
152
+ pairs_block = "\n".join(
153
+ f"{i}: expected={str(row.get(expected_col))[:150]!r} got={_row_output(row)[:150]!r}"
154
+ for i, (_, row) in enumerate(fail_rows)
155
+ )
156
+ prompt = ERROR_SIGNAL_PROMPT.format(pairs_block=pairs_block)
157
+ schema = {
158
+ "type": "array",
159
+ "items": {"type": "object", "required": ["index", "error"]},
160
+ }
161
+
162
+ for _attempt in range(max_repairs + 1):
163
+ raw = judge.generate(prompt)
164
+ text = raw.strip()
165
+ if text.startswith("```"):
166
+ text = re.sub(r"^```\w*\n?", "", text)
167
+ text = re.sub(r"\n?```\s*$", "", text)
168
+ try:
169
+ import json
170
+
171
+ data = json.loads(text)
172
+ except json.JSONDecodeError:
173
+ continue
174
+ if validate_json_shape(data, schema):
175
+ continue
176
+ by_index = {int(item["index"]): str(item["error"]) for item in data if "index" in item}
177
+ return [by_index.get(i, "") for i in range(len(fail_rows))]
178
+ return None
179
+
180
+
181
+ def _compute_error_signals(
182
+ fail_rows: list[tuple[int, dict[str, Any]]],
183
+ *,
184
+ expected_col: "str | None",
185
+ error_fn: "Callable[[dict[str, Any]], str] | None",
186
+ judge: "Model | None",
187
+ ) -> list[str]:
188
+ """Per-FAIL-row failure-mechanism strings, aligned with *fail_rows*.
189
+
190
+ Dispatch order: injected *error_fn* > batched LLM (if *judge* + *expected_col*
191
+ given) > deterministic expected-vs-output fallback > "" (no signal at all,
192
+ i.e. today's topic-only clustering) when neither is configured.
193
+ """
194
+ if error_fn is not None:
195
+ return [error_fn(row) for _, row in fail_rows]
196
+ if not expected_col:
197
+ return ["" for _ in fail_rows]
198
+ if judge is not None:
199
+ try:
200
+ signals = _error_signals_llm(fail_rows, expected_col, judge)
201
+ except Exception as exc: # noqa: BLE001 — error-signal generation is best-effort
202
+ logger.warning("cluster_failures: LLM error-signal generation failed: %s", exc)
203
+ signals = None
204
+ if signals is not None:
205
+ return signals
206
+ return [_error_signal_deterministic(row, expected_col) for _, row in fail_rows]
207
+
208
+
209
+ def _tokenize(text: str) -> list[str]:
210
+ return [t for t in re.findall(r"[a-z0-9]+", text.lower()) if len(t) > 2 and t not in _STOPWORDS]
211
+
212
+
213
+ def _top_terms(cluster_texts: list[str], corpus_doc_freq: Counter, n_docs: int, top_k: int = 5) -> list[str]:
214
+ """Rank words by in-cluster frequency weighted by corpus rarity (mini TF-IDF)."""
215
+ tf: Counter = Counter()
216
+ for text in cluster_texts:
217
+ tf.update(set(_tokenize(text)))
218
+ if not tf:
219
+ return []
220
+ import math
221
+
222
+ scored = [
223
+ (word, count * math.log((n_docs + 1) / (corpus_doc_freq.get(word, 0) + 1)))
224
+ for word, count in tf.items()
225
+ ]
226
+ scored.sort(key=lambda kv: kv[1], reverse=True)
227
+ return [w for w, _ in scored[:top_k]]
228
+
229
+
230
+ def _hash_vectorize(texts: list[str], n_features: int = 256):
231
+ """Pure-numpy fallback mirroring sklearn's TfidfVectorizer: word 1-2gram
232
+ hashing, smoothed IDF weighting, L2-normalized rows.
233
+
234
+ Word-level (2+ char tokens, like sklearn's default ``\\b\\w\\w+\\b``) plus
235
+ IDF is what keeps the two tiers consistent: shared boilerplate ("expected",
236
+ "got", …) is down-weighted so distinguishing content drives similarity, the
237
+ same way the TF-IDF path does. A raw char-3gram count instead lets common
238
+ character runs dominate, collapsing genuinely distinct failures together.
239
+ """
240
+ import numpy as np
241
+
242
+ def _terms(text: str) -> list[str]:
243
+ words = re.findall(r"\w\w+", text.lower())
244
+ if not words:
245
+ # No 2+ char word tokens (e.g. "q", punctuation): fall back to the
246
+ # raw text as a single term so identical short texts still map to
247
+ # identical non-zero vectors (and group) rather than empty rows.
248
+ return [text.lower().strip() or " "]
249
+ return words + [f"{words[k]} {words[k + 1]}" for k in range(len(words) - 1)]
250
+
251
+ counts = np.zeros((len(texts), n_features), dtype=float)
252
+ for i, text in enumerate(texts):
253
+ for term in _terms(text):
254
+ counts[i, zlib.crc32(term.encode("utf-8")) % n_features] += 1.0
255
+ # Smoothed IDF (sklearn smooth_idf default): terms present in every doc keep
256
+ # weight 1 rather than vanishing; rarer terms are up-weighted.
257
+ df = (counts > 0).sum(axis=0)
258
+ idf = np.log((1.0 + len(texts)) / (1.0 + df)) + 1.0
259
+ X = counts * idf
260
+ norms = np.linalg.norm(X, axis=1, keepdims=True)
261
+ norms[norms == 0] = 1.0
262
+ return X / norms
263
+
264
+
265
+ def _n_clusters_for(n_items: int, min_cluster_size: int, max_clusters: int) -> int:
266
+ return max(1, min(max_clusters, n_items // max(1, min_cluster_size)))
267
+
268
+
269
+ def _cluster_cosine_greedy(X, max_clusters: int, threshold: float = 0.25) -> list[int]:
270
+ """Deterministic numpy fallback: greedily group by cosine similarity to a
271
+ running cluster centroid, capped at max_clusters (excess assigned to the
272
+ nearest existing cluster instead of growing further)."""
273
+ import numpy as np
274
+
275
+ labels: list[int] = []
276
+ centroids: list[np.ndarray] = []
277
+ counts: list[int] = []
278
+ for row in X:
279
+ if centroids:
280
+ sims = [float(row @ c) for c in centroids]
281
+ best = int(np.argmax(sims))
282
+ else:
283
+ sims, best = [], -1
284
+ if best >= 0 and (sims[best] >= threshold or len(centroids) >= max_clusters):
285
+ centroids[best] = (centroids[best] * counts[best] + row) / (counts[best] + 1)
286
+ counts[best] += 1
287
+ labels.append(best)
288
+ else:
289
+ centroids.append(row.copy())
290
+ counts.append(1)
291
+ labels.append(len(centroids) - 1)
292
+ return labels
293
+
294
+
295
+ def _cluster_labels(X, *, method: str, min_cluster_size: int, max_clusters: int) -> tuple[list[int], str]:
296
+ """Return (labels, method_used). Tries the requested/auto method, falling
297
+ back down the chain: hdbscan -> sklearn Agglomerative -> numpy cosine-greedy."""
298
+ tried_auto = method == "auto"
299
+
300
+ if method in ("auto", "hdbscan"):
301
+ try:
302
+ import hdbscan as _hdbscan
303
+
304
+ labels = _hdbscan.HDBSCAN(min_cluster_size=max(2, min_cluster_size)).fit_predict(X)
305
+ return list(int(x) for x in labels), "hdbscan"
306
+ except ImportError:
307
+ if not tried_auto:
308
+ raise
309
+
310
+ if method in ("auto", "agglomerative"):
311
+ try:
312
+ from sklearn.cluster import AgglomerativeClustering
313
+
314
+ n_clusters = _n_clusters_for(len(X), min_cluster_size, max_clusters)
315
+ model = AgglomerativeClustering(n_clusters=n_clusters, metric="cosine", linkage="average")
316
+ labels = model.fit_predict(X)
317
+ return list(int(x) for x in labels), "agglomerative"
318
+ except ImportError:
319
+ if not tried_auto:
320
+ raise
321
+
322
+ labels = _cluster_cosine_greedy(X, max_clusters)
323
+ return labels, "cosine_greedy"
324
+
325
+
326
+ def _vectorize(texts: list[str]) -> tuple[Any, str]:
327
+ """Try sklearn TF-IDF; fall back to the pure-numpy hashing vectorizer.
328
+
329
+ Falls back on any failure (not just a missing import) — e.g. degenerate
330
+ all-empty-text input raises sklearn's "empty vocabulary" ValueError,
331
+ which the hashing vectorizer handles fine (an all-zero row).
332
+ """
333
+ try:
334
+ from sklearn.feature_extraction.text import TfidfVectorizer
335
+
336
+ vec = TfidfVectorizer(max_features=512, ngram_range=(1, 2))
337
+ X = vec.fit_transform(texts).toarray()
338
+ return X, "tfidf"
339
+ except Exception: # noqa: BLE001 — any vectorization failure falls back
340
+ return _hash_vectorize(texts), "hashing"
341
+
342
+
343
+ def _rank_by_centroid_distance(
344
+ idxs: list[int], X: Any, n_core: int, n_boundary: int
345
+ ) -> tuple[list[int], list[int]]:
346
+ """Split cluster member indices (into *X*) into (core, boundary): the
347
+ *n_core* closest to the cluster centroid (most representative) and the
348
+ *n_boundary* farthest (peripheral — nearest the failure/success edge)."""
349
+ import numpy as np
350
+
351
+ centroid = X[idxs].mean(axis=0)
352
+ order = sorted(idxs, key=lambda i: float(np.linalg.norm(X[i] - centroid)))
353
+ core = order[:n_core]
354
+ boundary = order[-n_boundary:] if len(order) > n_core else []
355
+ return core, boundary
356
+
357
+
358
+ def _boundary_contrast_pairs(
359
+ boundary_idxs: list[int],
360
+ X: Any,
361
+ fail_rows: list[tuple[int, dict[str, Any]]],
362
+ pass_rows: list[dict[str, Any]],
363
+ X_pass: Any,
364
+ ) -> list[dict[str, Any]]:
365
+ """For each boundary FAIL index, pair it with its nearest PASS row (cosine
366
+ similarity in the same vector space — rows are L2-normalized by both
367
+ vectorizer tiers, so a dot product approximates cosine similarity)."""
368
+ if not boundary_idxs or not pass_rows or len(X_pass) == 0:
369
+ return []
370
+ import numpy as np
371
+
372
+ pairs = []
373
+ for i in boundary_idxs:
374
+ sims = X_pass @ X[i]
375
+ best = int(np.argmax(sims))
376
+ pairs.append({
377
+ "fail": fail_rows[i][1],
378
+ "nearest_pass": pass_rows[best],
379
+ "similarity": float(sims[best]),
380
+ })
381
+ return pairs
382
+
383
+
384
+ def _name_clusters_with_llm(
385
+ judge: "Model", clusters: list[FailureMode], *, max_repairs: int = 1
386
+ ) -> bool:
387
+ """Best-effort: ask *judge* to name/describe each cluster from its
388
+ exemplars. Returns True on success (clusters updated in place)."""
389
+ from evalrx.analysis.prompts.failure_modes import NAME_CLUSTERS_PROMPT
390
+
391
+ blocks = []
392
+ for i, c in enumerate(clusters):
393
+ exemplar_texts = [str(e) for e in c.exemplars[:3]]
394
+ block = (
395
+ f"Cluster {i} (n={c.size}, top terms: {', '.join(c.top_terms)}):\n"
396
+ + "\n".join(f" - {t[:200]}" for t in exemplar_texts)
397
+ )
398
+ if c.boundary_pairs:
399
+ contrast_lines = [
400
+ f" - FAIL: {str(bp['fail'])[:150]} | nearest PASS: {str(bp['nearest_pass'])[:150]}"
401
+ for bp in c.boundary_pairs[:3]
402
+ ]
403
+ block += "\n Boundary contrast (failure vs. nearest success):\n" + "\n".join(contrast_lines)
404
+ blocks.append(block)
405
+ prompt = NAME_CLUSTERS_PROMPT.format(clusters_block="\n\n".join(blocks))
406
+
407
+ schema = {
408
+ "type": "array",
409
+ "items": {
410
+ "type": "object",
411
+ "required": ["cluster_id", "name", "description"],
412
+ "properties": {"name": {"type": "string", "minLength": 1}},
413
+ },
414
+ }
415
+
416
+ for _attempt in range(max_repairs + 1):
417
+ raw = judge.generate(prompt)
418
+ text = raw.strip()
419
+ if text.startswith("```"):
420
+ text = re.sub(r"^```\w*\n?", "", text)
421
+ text = re.sub(r"\n?```\s*$", "", text)
422
+ try:
423
+ import json
424
+
425
+ data = json.loads(text)
426
+ except json.JSONDecodeError:
427
+ continue
428
+ if validate_json_shape(data, schema):
429
+ continue
430
+ by_id = {int(item["cluster_id"]): item for item in data if "cluster_id" in item}
431
+ for i, c in enumerate(clusters):
432
+ item = by_id.get(i)
433
+ if item:
434
+ c.name = str(item["name"])
435
+ c.description = str(item.get("description", c.description))
436
+ return True
437
+ return False
438
+
439
+
440
+ def cluster_failures(
441
+ records: list[dict[str, Any]],
442
+ *,
443
+ outcome_col: str = "label",
444
+ fail_value: str = "FAIL",
445
+ text_cols: "list[str] | None" = None,
446
+ signal_cols: "list[str] | None" = None,
447
+ id_key: str = "case_id",
448
+ judge: "Model | None" = None,
449
+ min_cluster_size: int = 3,
450
+ max_clusters: int = 8,
451
+ n_exemplars: int = 3,
452
+ method: str = "auto",
453
+ expected_col: "str | None" = None,
454
+ error_fn: "Callable[[dict[str, Any]], str] | None" = None,
455
+ boundary_aware: bool = False,
456
+ n_boundary: int = 2,
457
+ ) -> FailureModeReport:
458
+ """Cluster FAIL cases in *records* into interpretable failure modes.
459
+
460
+ Two tiers, no required dependency:
461
+
462
+ - Deterministic: TF-IDF (sklearn, if installed) or a pure-numpy word-1-2gram
463
+ IDF hashing vectorizer over each case's text + active signal flags, then
464
+ hdbscan / sklearn Agglomerative / a numpy cosine-greedy fallback —
465
+ whichever is available, in that preference order (or force one via
466
+ *method*: ``"hdbscan"``, ``"agglomerative"``, ``"cosine_greedy"``).
467
+ - LLM (optional): when *judge* is given, one call names/describes each
468
+ cluster from its exemplars; falls back to corpus-weighted top terms
469
+ on a malformed response.
470
+
471
+ *text_cols*/*signal_cols* default to auto-detected columns (common text
472
+ field names; all numeric/boolean columns for signals). *outcome_col*/
473
+ *fail_value* select which records count as FAIL (case-insensitive).
474
+
475
+ Two opt-in, off-by-default extensions (ProbeLLM-style failure-mode
476
+ synthesis) — both no-ops unless explicitly requested, so existing callers
477
+ see byte-identical output:
478
+
479
+ - *expected_col*/*error_fn*: fold a failure-*mechanism* signal into each
480
+ FAIL case's vectorized text, so grouping reflects how the model failed
481
+ rather than only what the prompt was about. *error_fn* (row -> str) is
482
+ used verbatim when given; otherwise, if *judge* and *expected_col* are
483
+ both set, one batched LLM call describes each mismatch, falling back to
484
+ a deterministic "expected=... got=..." string on any judge/parse
485
+ failure. With neither, behavior is unchanged (topic-only clustering).
486
+ - *boundary_aware*: also collect non-FAIL rows as a contrast pool. For
487
+ each cluster, the *n_boundary* cases farthest from the cluster centroid
488
+ are paired with their nearest verified non-failure (by cosine
489
+ similarity in the same vector space) into ``FailureMode.boundary_pairs``
490
+ — surfaced to the LLM namer (when *judge* is given) to describe *where*
491
+ the failure boundary sits, not just what the failing cases share.
492
+ """
493
+ params = {
494
+ "outcome_col": outcome_col, "fail_value": fail_value,
495
+ "min_cluster_size": min_cluster_size, "max_clusters": max_clusters,
496
+ }
497
+ fail_rows = [
498
+ (i, r) for i, r in enumerate(records)
499
+ if str(r.get(outcome_col, "")).strip().lower() == fail_value.strip().lower()
500
+ ]
501
+ if not fail_rows:
502
+ return FailureModeReport(n_fail_cases=0, method="none", params=params)
503
+
504
+ pass_rows: list[dict[str, Any]] = []
505
+ if boundary_aware:
506
+ pass_rows = [
507
+ r for i, r in enumerate(records)
508
+ if str(r.get(outcome_col, "")).strip().lower() != fail_value.strip().lower()
509
+ ]
510
+
511
+ ids = [_row_id(r, i, id_key) for i, r in fail_rows]
512
+ exclude = {outcome_col, id_key, *(text_cols or [])}
513
+ error_signals = _compute_error_signals(
514
+ fail_rows, expected_col=expected_col, error_fn=error_fn, judge=judge,
515
+ )
516
+ texts = [
517
+ (
518
+ _row_text(r, text_cols) + " " + _row_signal_flags(r, signal_cols, exclude)
519
+ + " " + err
520
+ ).strip()
521
+ for (_, r), err in zip(fail_rows, error_signals)
522
+ ]
523
+
524
+ if len(fail_rows) < max(2, min_cluster_size):
525
+ exemplars = [r for _, r in fail_rows[:n_exemplars]]
526
+ corpus_freq: Counter = Counter()
527
+ for t in texts:
528
+ corpus_freq.update(set(_tokenize(t)))
529
+ cluster = FailureMode(
530
+ name="all_failures", description="Too few FAIL cases to cluster meaningfully.",
531
+ case_ids=ids, exemplars=exemplars, size=len(ids),
532
+ top_terms=_top_terms(texts, corpus_freq, len(texts)),
533
+ )
534
+ report = FailureModeReport(
535
+ clusters=[cluster], n_fail_cases=len(fail_rows),
536
+ method="single_cluster", params=params,
537
+ )
538
+ if judge is not None:
539
+ try:
540
+ if _name_clusters_with_llm(judge, report.clusters):
541
+ report.named_by = f"llm:{judge!r}"
542
+ except Exception as exc: # noqa: BLE001 — naming is best-effort
543
+ logger.warning("cluster_failures: LLM naming failed: %s", exc)
544
+ return report
545
+
546
+ pass_texts = [
547
+ (_row_text(r, text_cols) + " " + _row_signal_flags(r, signal_cols, exclude)).strip()
548
+ for r in pass_rows
549
+ ]
550
+ if pass_texts:
551
+ X_combined, vec_method = _vectorize(texts + pass_texts)
552
+ X, X_pass = X_combined[: len(texts)], X_combined[len(texts):]
553
+ else:
554
+ X, vec_method = _vectorize(texts)
555
+ X_pass = X[:0] # empty, same width — no contrast pool available
556
+
557
+ labels, cluster_method = _cluster_labels(
558
+ X, method=method, min_cluster_size=min_cluster_size, max_clusters=max_clusters
559
+ )
560
+
561
+ corpus_freq: Counter = Counter()
562
+ for t in texts:
563
+ corpus_freq.update(set(_tokenize(t)))
564
+
565
+ by_label: dict[int, list[int]] = {}
566
+ for idx, label in enumerate(labels):
567
+ by_label.setdefault(label, []).append(idx)
568
+
569
+ clusters: list[FailureMode] = []
570
+ unclustered_ids: list[str] = []
571
+ for label, idxs in sorted(by_label.items()):
572
+ cluster_ids = [ids[i] for i in idxs]
573
+ if label == -1: # hdbscan noise
574
+ unclustered_ids.extend(cluster_ids)
575
+ continue
576
+ cluster_texts = [texts[i] for i in idxs]
577
+ top_terms = _top_terms(cluster_texts, corpus_freq, len(texts))
578
+ core_idxs, boundary_idxs = _rank_by_centroid_distance(idxs, X, n_exemplars, n_boundary)
579
+ clusters.append(FailureMode(
580
+ name=f"cluster_{label}_" + ("_".join(top_terms[:2]) or "misc"),
581
+ description=(
582
+ f"{len(idxs)} FAIL case(s) sharing: {', '.join(top_terms)}"
583
+ if top_terms else f"{len(idxs)} FAIL case(s) with no distinguishing terms."
584
+ ),
585
+ case_ids=cluster_ids,
586
+ exemplars=[fail_rows[i][1] for i in core_idxs],
587
+ size=len(idxs),
588
+ top_terms=top_terms,
589
+ boundary_pairs=(
590
+ _boundary_contrast_pairs(boundary_idxs, X, fail_rows, pass_rows, X_pass)
591
+ if boundary_aware else []
592
+ ),
593
+ ))
594
+
595
+ report = FailureModeReport(
596
+ clusters=clusters, n_fail_cases=len(fail_rows), unclustered_ids=unclustered_ids,
597
+ method=cluster_method, params={**params, "vectorizer": vec_method},
598
+ )
599
+
600
+ if judge is not None and clusters:
601
+ try:
602
+ if _name_clusters_with_llm(judge, clusters):
603
+ report.named_by = f"llm:{judge!r}"
604
+ except Exception as exc: # noqa: BLE001 — naming is best-effort
605
+ logger.warning("cluster_failures: LLM naming failed: %s", exc)
606
+
607
+ return report