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.
- evalrx/__init__.py +139 -0
- evalrx/agent_assets/__init__.py +2 -0
- evalrx/agent_assets/skills/README.md +28 -0
- evalrx/agent_assets/skills/eval-chart-style/SKILL.md +172 -0
- evalrx/agent_assets/skills/evalrx-report-ui/SKILL.md +116 -0
- evalrx/agent_assets/skills/nature-figure/LICENSE +201 -0
- evalrx/agent_assets/skills/nature-figure/README.md +412 -0
- evalrx/agent_assets/skills/nature-figure/SKILL.md +60 -0
- evalrx/agent_assets/skills/nature-figure/manifest.yaml +59 -0
- evalrx/agent_assets/skills/nature-figure/references/api.md +436 -0
- evalrx/agent_assets/skills/nature-figure/references/backend-selection.md +100 -0
- evalrx/agent_assets/skills/nature-figure/references/chart-types.md +281 -0
- evalrx/agent_assets/skills/nature-figure/references/common-patterns.md +350 -0
- evalrx/agent_assets/skills/nature-figure/references/demos.md +65 -0
- evalrx/agent_assets/skills/nature-figure/references/design-theory.md +439 -0
- evalrx/agent_assets/skills/nature-figure/references/figure-contract.md +93 -0
- evalrx/agent_assets/skills/nature-figure/references/figure-legend-conventions.md +71 -0
- evalrx/agent_assets/skills/nature-figure/references/nature-2026-observations.md +112 -0
- evalrx/agent_assets/skills/nature-figure/references/qa-contract.md +119 -0
- evalrx/agent_assets/skills/nature-figure/references/r-template-index.md +66 -0
- evalrx/agent_assets/skills/nature-figure/references/r-workflow.md +161 -0
- evalrx/agent_assets/skills/nature-figure/references/tutorials.md +251 -0
- evalrx/agent_assets/skills/nature-figure/static/core/contract.md +29 -0
- evalrx/agent_assets/skills/nature-figure/static/core/stance.md +37 -0
- evalrx/agent_assets/skills/nature-figure/static/fragments/backend/python.md +37 -0
- evalrx/agent_assets/skills/nature-figure/static/fragments/backend/r.md +44 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/SKILL.md +213 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/assets/analysis_report_template.md +53 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/references/model_selection.md +72 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/explanatory_var_eda.R +130 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/explanatory_var_eda.py +150 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/fit_outcome_model.R +181 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/fit_outcome_model.py +186 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/univariate_eda.R +149 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/univariate_eda.py +177 -0
- evalrx/agent_assets/skills.py +27 -0
- evalrx/agent_runtime/__init__.py +78 -0
- evalrx/agent_runtime/_docker_runner.py +89 -0
- evalrx/agent_runtime/cli_runtime.py +103 -0
- evalrx/agent_runtime/cli_transcript.py +138 -0
- evalrx/agent_runtime/cli_types.py +68 -0
- evalrx/agent_runtime/codegen/__init__.py +5 -0
- evalrx/agent_runtime/codegen/runner.py +94 -0
- evalrx/agent_runtime/experiment_harness.py +117 -0
- evalrx/agent_runtime/factory.py +102 -0
- evalrx/agent_runtime/json_shape.py +44 -0
- evalrx/agent_runtime/judges/__init__.py +28 -0
- evalrx/agent_runtime/judges/agy.py +179 -0
- evalrx/agent_runtime/judges/autodetect.py +135 -0
- evalrx/agent_runtime/judges/claude.py +159 -0
- evalrx/agent_runtime/judges/codex.py +120 -0
- evalrx/agent_runtime/providers/__init__.py +21 -0
- evalrx/agent_runtime/providers/antigravity.py +31 -0
- evalrx/agent_runtime/providers/base.py +145 -0
- evalrx/agent_runtime/providers/claude_code.py +49 -0
- evalrx/agent_runtime/providers/codex.py +37 -0
- evalrx/agent_runtime/providers/gemini_cli.py +26 -0
- evalrx/agent_runtime/providers/kimi_cli.py +27 -0
- evalrx/agent_runtime/providers/opencode.py +27 -0
- evalrx/agent_runtime/providers/registry.py +58 -0
- evalrx/agent_runtime/sandbox.py +517 -0
- evalrx/agent_runtime/skill_audit.py +143 -0
- evalrx/agent_runtime/skills/__init__.py +19 -0
- evalrx/agent_runtime/skills/installer.py +68 -0
- evalrx/agent_runtime/skills/prompt_policy.py +86 -0
- evalrx/agent_runtime/skills/resolver.py +19 -0
- evalrx/analysis/__init__.py +132 -0
- evalrx/analysis/adjudicate.py +154 -0
- evalrx/analysis/analysis_module.py +361 -0
- evalrx/analysis/api.py +171 -0
- evalrx/analysis/case_studio.py +651 -0
- evalrx/analysis/cli.py +114 -0
- evalrx/analysis/dashboard.py +350 -0
- evalrx/analysis/eval_case_matrix.py +118 -0
- evalrx/analysis/eval_viz_theme.py +833 -0
- evalrx/analysis/explore_run.py +333 -0
- evalrx/analysis/explorer.py +1276 -0
- evalrx/analysis/failure_modes.py +607 -0
- evalrx/analysis/fused_pipeline.py +489 -0
- evalrx/analysis/holdout.py +300 -0
- evalrx/analysis/hypothesis_agent.py +230 -0
- evalrx/analysis/narration.py +177 -0
- evalrx/analysis/operationalize.py +442 -0
- evalrx/analysis/plain_language.py +42 -0
- evalrx/analysis/planner.py +283 -0
- evalrx/analysis/probe_search.py +203 -0
- evalrx/analysis/profile.py +268 -0
- evalrx/analysis/prompts/__init__.py +0 -0
- evalrx/analysis/prompts/explorer.py +417 -0
- evalrx/analysis/prompts/failure_modes.py +33 -0
- evalrx/analysis/prompts/holdout.py +27 -0
- evalrx/analysis/prompts/hypothesis_agent.py +78 -0
- evalrx/analysis/prompts/run_codebase.py +47 -0
- evalrx/analysis/prompts/stats_agent.py +72 -0
- evalrx/analysis/prompts/stats_tool_generator.py +43 -0
- evalrx/analysis/result_marker.py +47 -0
- evalrx/analysis/run_codebase.py +242 -0
- evalrx/analysis/run_view.py +205 -0
- evalrx/analysis/stage_views.py +93 -0
- evalrx/analysis/stats_agent.py +944 -0
- evalrx/analysis/stats_tool_agent.py +261 -0
- evalrx/analysis/stats_tool_generator.py +415 -0
- evalrx/analysis/stats_tools.py +1153 -0
- evalrx/analysis/trajectory_records.py +193 -0
- evalrx/analysis/workbench.py +431 -0
- evalrx/analyzers/__init__.py +42 -0
- evalrx/analyzers/agent/__init__.py +25 -0
- evalrx/analyzers/agent/counterfactual.py +84 -0
- evalrx/analyzers/agent/first_error_judge.py +96 -0
- evalrx/analyzers/agent/ignored_obs.py +81 -0
- evalrx/analyzers/agent/loop_detect.py +79 -0
- evalrx/analyzers/agent/reliability.py +165 -0
- evalrx/analyzers/agent/tool_shap.py +225 -0
- evalrx/analyzers/agent/trajectory_rubric.py +168 -0
- evalrx/analyzers/attention/__init__.py +19 -0
- evalrx/analyzers/attention/relative_attn.py +610 -0
- evalrx/analyzers/attention/rollout.py +73 -0
- evalrx/analyzers/attention/sink.py +56 -0
- evalrx/analyzers/attention/summary.py +190 -0
- evalrx/analyzers/attribution/__init__.py +6 -0
- evalrx/analyzers/attribution/generic_attn.py +31 -0
- evalrx/analyzers/attribution/gradcam.py +30 -0
- evalrx/analyzers/base.py +12 -0
- evalrx/analyzers/geometry/__init__.py +6 -0
- evalrx/analyzers/geometry/cka.py +70 -0
- evalrx/analyzers/geometry/linear_probe.py +157 -0
- evalrx/analyzers/hallucination/__init__.py +9 -0
- evalrx/analyzers/hallucination/chair.py +78 -0
- evalrx/analyzers/hallucination/opera.py +29 -0
- evalrx/analyzers/hallucination/pope.py +119 -0
- evalrx/analyzers/hallucination/selfcheck.py +155 -0
- evalrx/analyzers/hallucination/vcd.py +29 -0
- evalrx/analyzers/lens/__init__.py +7 -0
- evalrx/analyzers/lens/layer_contrast.py +133 -0
- evalrx/analyzers/lens/logit_lens.py +138 -0
- evalrx/analyzers/lens/tuned_lens.py +30 -0
- evalrx/analyzers/patching/__init__.py +5 -0
- evalrx/analyzers/patching/causal_trace.py +30 -0
- evalrx/analyzers/perturbation/__init__.py +23 -0
- evalrx/analyzers/perturbation/_shapley.py +54 -0
- evalrx/analyzers/perturbation/context_shap.py +174 -0
- evalrx/analyzers/perturbation/cot_faithfulness.py +239 -0
- evalrx/analyzers/perturbation/format_sensitivity.py +237 -0
- evalrx/analyzers/perturbation/mm_shap.py +146 -0
- evalrx/analyzers/perturbation/modality_ablation.py +196 -0
- evalrx/analyzers/perturbation/perturbation_battery.py +274 -0
- evalrx/analyzers/perturbation/prompt_contrast.py +265 -0
- evalrx/analyzers/perturbation/rise.py +94 -0
- evalrx/analyzers/perturbation/vl_shap.py +102 -0
- evalrx/analyzers/reasoning/__init__.py +33 -0
- evalrx/analyzers/reasoning/_text.py +328 -0
- evalrx/analyzers/reasoning/answer_extraction_audit.py +327 -0
- evalrx/analyzers/reasoning/arith_audit.py +226 -0
- evalrx/analyzers/reasoning/contamination.py +214 -0
- evalrx/analyzers/reasoning/knowledge_split.py +253 -0
- evalrx/analyzers/reasoning/self_repair.py +246 -0
- evalrx/analyzers/reasoning/step_rollout_value.py +216 -0
- evalrx/analyzers/reasoning/termination_audit.py +258 -0
- evalrx/analyzers/uncertainty/__init__.py +18 -0
- evalrx/analyzers/uncertainty/calibration.py +174 -0
- evalrx/analyzers/uncertainty/coverage_gap.py +199 -0
- evalrx/analyzers/uncertainty/entropy.py +90 -0
- evalrx/analyzers/uncertainty/logprob_entropy.py +69 -0
- evalrx/analyzers/uncertainty/self_consistency.py +204 -0
- evalrx/analyzers/uncertainty/verbalized_conf.py +64 -0
- evalrx/cli.py +411 -0
- evalrx/config.py +77 -0
- evalrx/contract/__init__.py +179 -0
- evalrx/contract/common.py +452 -0
- evalrx/contract/emit.py +948 -0
- evalrx/contract/export.py +237 -0
- evalrx/contract/m1.py +325 -0
- evalrx/contract/m2.py +317 -0
- evalrx/contract/m3.py +165 -0
- evalrx/contract/m4.py +130 -0
- evalrx/contract/m5.py +292 -0
- evalrx/contract/methodology.py +76 -0
- evalrx/contract/pre_m1.py +58 -0
- evalrx/contract/typescript.py +140 -0
- evalrx/core/__init__.py +85 -0
- evalrx/core/analyzer.py +174 -0
- evalrx/core/capability.py +54 -0
- evalrx/core/case.py +443 -0
- evalrx/core/experiment.py +106 -0
- evalrx/core/model.py +198 -0
- evalrx/core/pipeline.py +42 -0
- evalrx/core/registry.py +142 -0
- evalrx/core/result.py +64 -0
- evalrx/core/spec.py +173 -0
- evalrx/core/tokentype.py +165 -0
- evalrx/core/tool.py +92 -0
- evalrx/datasets/__init__.py +41 -0
- evalrx/datasets/base.py +68 -0
- evalrx/datasets/gui_os.py +52 -0
- evalrx/datasets/llm_qa.py +57 -0
- evalrx/datasets/pure_qa.py +12 -0
- evalrx/datasets/vlm_qa.py +695 -0
- evalrx/datasets/web_search_qa.py +52 -0
- evalrx/eval_agent/__init__.py +341 -0
- evalrx/eval_agent/_tools.py +81 -0
- evalrx/eval_agent/ab_runner.py +50 -0
- evalrx/eval_agent/agentic/__init__.py +43 -0
- evalrx/eval_agent/agentic/actions.py +216 -0
- evalrx/eval_agent/agentic/board.py +107 -0
- evalrx/eval_agent/agentic/loop.py +190 -0
- evalrx/eval_agent/agentic/tools.py +538 -0
- evalrx/eval_agent/checkpoint.py +57 -0
- evalrx/eval_agent/cli_agent.py +59 -0
- evalrx/eval_agent/cli_skills.py +5 -0
- evalrx/eval_agent/evolution.py +396 -0
- evalrx/eval_agent/git_manager.py +215 -0
- evalrx/eval_agent/hypothesis.py +172 -0
- evalrx/eval_agent/label_quarantine.py +209 -0
- evalrx/eval_agent/legacy.py +530 -0
- evalrx/eval_agent/log_schema.py +497 -0
- evalrx/eval_agent/loop.py +2159 -0
- evalrx/eval_agent/loop_reports.py +116 -0
- evalrx/eval_agent/model_instrumentation.py +282 -0
- evalrx/eval_agent/narration.py +193 -0
- evalrx/eval_agent/nl_runner.py +460 -0
- evalrx/eval_agent/orchestrator.py +61 -0
- evalrx/eval_agent/preregister.py +93 -0
- evalrx/eval_agent/prompts/__init__.py +1 -0
- evalrx/eval_agent/prompts/agentic.py +46 -0
- evalrx/eval_agent/prompts/case_discovery.py +25 -0
- evalrx/eval_agent/prompts/diagnosis.py +125 -0
- evalrx/eval_agent/prompts/experiment_writer.py +265 -0
- evalrx/eval_agent/prompts/explore_step.py +37 -0
- evalrx/eval_agent/prompts/fix_agent.py +257 -0
- evalrx/eval_agent/prompts/hypothesis_tester.py +15 -0
- evalrx/eval_agent/prompts/nl_runner.py +38 -0
- evalrx/eval_agent/prompts/probe_agent.py +25 -0
- evalrx/eval_agent/prompts/probe_candidate_generator.py +14 -0
- evalrx/eval_agent/prompts/probe_generator.py +35 -0
- evalrx/eval_agent/prompts/whitebox_probe_generator.py +38 -0
- evalrx/eval_agent/report.py +58 -0
- evalrx/eval_agent/run_context.py +354 -0
- evalrx/eval_agent/run_log.schema.json +1215 -0
- evalrx/eval_agent/run_logger_v2.py +1764 -0
- evalrx/eval_agent/run_metadata.py +208 -0
- evalrx/eval_agent/stages/__init__.py +56 -0
- evalrx/eval_agent/stages/case_discovery.py +293 -0
- evalrx/eval_agent/stages/diagnosis.py +1017 -0
- evalrx/eval_agent/stages/experiment_writer.py +1634 -0
- evalrx/eval_agent/stages/fix_agent.py +3916 -0
- evalrx/eval_agent/stages/fix_internals.py +499 -0
- evalrx/eval_agent/stages/fix_pipeline.py +725 -0
- evalrx/eval_agent/stages/fix_tiers.py +187 -0
- evalrx/eval_agent/stages/fix_tools.py +1034 -0
- evalrx/eval_agent/stages/hypothesis_tester.py +1014 -0
- evalrx/eval_agent/stages/probe.py +439 -0
- evalrx/eval_agent/stages/probe_agent.py +1079 -0
- evalrx/eval_agent/stages/probe_candidate_generator.py +128 -0
- evalrx/eval_agent/stages/probe_generator.py +326 -0
- evalrx/eval_agent/stages/probe_search_agent.py +106 -0
- evalrx/eval_agent/stages/protocol.py +112 -0
- evalrx/eval_agent/stages/repair_catalog.py +273 -0
- evalrx/eval_agent/stages/surgery.py +524 -0
- evalrx/eval_agent/stages/whitebox_probe_generator.py +351 -0
- evalrx/eval_agent/store.py +231 -0
- evalrx/logging_utils.py +112 -0
- evalrx/models/__init__.py +161 -0
- evalrx/models/_discover.py +101 -0
- evalrx/models/agent.py +380 -0
- evalrx/models/backends/__init__.py +58 -0
- evalrx/models/backends/api.py +169 -0
- evalrx/models/backends/base.py +57 -0
- evalrx/models/backends/gemini_compat.py +579 -0
- evalrx/models/backends/hf_local.py +2074 -0
- evalrx/models/backends/openai_compat.py +301 -0
- evalrx/models/backends/vllm_offline.py +116 -0
- evalrx/models/base.py +24 -0
- evalrx/models/blackbox/__init__.py +4 -0
- evalrx/models/blackbox/agent.py +31 -0
- evalrx/models/blackbox/base.py +29 -0
- evalrx/models/blackbox/gemini.py +279 -0
- evalrx/models/blackbox/llm_api.py +17 -0
- evalrx/models/blackbox/vlm_api.py +17 -0
- evalrx/models/compose.py +66 -0
- evalrx/models/inference.py +88 -0
- evalrx/models/paper_methods/__init__.py +8 -0
- evalrx/models/paper_methods/aad.py +53 -0
- evalrx/models/paper_methods/ifcd.py +204 -0
- evalrx/models/paper_methods/pai.py +164 -0
- evalrx/models/paper_methods/tcd.py +202 -0
- evalrx/models/paper_methods/vcd.py +45 -0
- evalrx/models/paper_methods/vicrop.py +137 -0
- evalrx/models/toolcodec.py +143 -0
- evalrx/models/tools/__init__.py +20 -0
- evalrx/models/tools/perception.py +300 -0
- evalrx/models/tools/visual.py +174 -0
- evalrx/models/whitebox/__init__.py +26 -0
- evalrx/models/whitebox/agent.py +31 -0
- evalrx/models/whitebox/base.py +24 -0
- evalrx/models/whitebox/qwen.py +61 -0
- evalrx/models/whitebox/qwen2_5_omni.py +29 -0
- evalrx/models/whitebox/qwen2_audio.py +25 -0
- evalrx/models/whitebox/qwen_omni.py +53 -0
- evalrx/models/whitebox/qwen_vl.py +62 -0
- evalrx/observability/__init__.py +21 -0
- evalrx/observability/envelope.py +122 -0
- evalrx/observability/outbox.py +111 -0
- evalrx/observability/tracer.py +882 -0
- evalrx/reporting/__init__.py +28 -0
- evalrx/reporting/case_study.py +947 -0
- evalrx/reporting/compiler.py +587 -0
- evalrx/reporting/dynamic.py +1882 -0
- evalrx/reporting/html_report.py +2225 -0
- evalrx/reporting/langfuse_exporter.py +38 -0
- evalrx/reporting/langfuse_source.py +155 -0
- evalrx/reporting/model.py +151 -0
- evalrx/reporting/run_events.py +184 -0
- evalrx/reporting/server.py +557 -0
- evalrx/reporting/stages.py +58 -0
- evalrx/reporting/static_export.py +142 -0
- evalrx/reporting/web_dist/index.html +146 -0
- evalrx/specs.py +727 -0
- evalrx/stats/__init__.py +47 -0
- evalrx/stats/api.py +192 -0
- evalrx/stats/bootstrap.py +86 -0
- evalrx/stats/ebh.py +27 -0
- evalrx/stats/evalue.py +98 -0
- evalrx/stats/friedman.py +138 -0
- evalrx/stats/mcnemar.py +40 -0
- evalrx/stats/multiplicity.py +159 -0
- evalrx/stats/subset_sampling.py +55 -0
- evalrx/term_links.py +43 -0
- evalrx/viz/__init__.py +7 -0
- evalrx/viz/labels.py +77 -0
- evalrx/viz/prompts.py +39 -0
- evalrx/viz/renderer.py +590 -0
- evalrx/viz/schema.py +36 -0
- evalrx/viz/style.py +134 -0
- evalrx-0.1.2.dist-info/METADATA +532 -0
- evalrx-0.1.2.dist-info/RECORD +339 -0
- evalrx-0.1.2.dist-info/WHEEL +5 -0
- evalrx-0.1.2.dist-info/entry_points.txt +3 -0
- evalrx-0.1.2.dist-info/licenses/LICENSE +121 -0
- 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
|