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
evalrx/core/analyzer.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Analyzer — the sklearn-like estimator at the heart of EvalRX.
|
|
2
|
+
|
|
3
|
+
Consistent contract, every analyzer:
|
|
4
|
+
- is configured with hyper-parameters in ``__init__`` (stored, introspectable
|
|
5
|
+
via ``get_params``/``set_params`` — exactly like a scikit-learn estimator),
|
|
6
|
+
- declares the capabilities it ``requires``,
|
|
7
|
+
- runs via ``run(model, data) -> Result``.
|
|
8
|
+
|
|
9
|
+
``run`` normalises ``data`` into a :class:`CaseBatch`, verifies the model
|
|
10
|
+
provides the required capabilities (clear :class:`CapabilityError` otherwise),
|
|
11
|
+
then delegates to the subclass's :meth:`_run`. Subclasses implement only
|
|
12
|
+
``_run`` and never repeat the boilerplate.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import inspect
|
|
18
|
+
from abc import ABC, abstractmethod
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
from evalrx.core.capability import Capability, CapabilityError
|
|
22
|
+
from evalrx.core.case import CaseBatch, as_casebatch
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from evalrx.core.model import Model
|
|
26
|
+
from evalrx.core.result import Result
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Analyzer(ABC):
|
|
30
|
+
"""Abstract base for every analysis (sklearn-estimator style).
|
|
31
|
+
|
|
32
|
+
Class attributes (set by subclasses):
|
|
33
|
+
name: Registered short name (e.g. ``"attention"``).
|
|
34
|
+
requires: Capabilities the analysed model must provide.
|
|
35
|
+
|
|
36
|
+
Example::
|
|
37
|
+
|
|
38
|
+
result = AttentionAnalyzer(layer=-1).run(qwen, "The capital of France is")
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
name: str = "analyzer"
|
|
42
|
+
requires: frozenset[Capability] = frozenset()
|
|
43
|
+
#: Modalities this analysis applies to; matched against ``model.modalities``.
|
|
44
|
+
#: ``{"text"}`` runs on any text-capable model; ``{"image"}`` only on VLMs.
|
|
45
|
+
applies_to_modalities: frozenset[str] = frozenset({"text"})
|
|
46
|
+
#: True when the analysis reads multi-step agent runs
|
|
47
|
+
#: (:class:`~evalrx.core.case.Trajectory`) rather than single turns.
|
|
48
|
+
#:
|
|
49
|
+
#: A requirement on the DATA, which is why it is not a
|
|
50
|
+
#: :class:`~evalrx.core.capability.Capability`: these analyzers run on
|
|
51
|
+
#: trajectories loaded from disk with ``model=None``, so nothing about the
|
|
52
|
+
#: model can express it. ``requires``/``applies_to_modalities`` therefore let
|
|
53
|
+
#: every one of them match a plain LLM or VLM, and a single-turn QA batch got
|
|
54
|
+
#: offered trajectory analyzers — the ones that built with default args then
|
|
55
|
+
#: ran and reported ``n_trajectories: 0``. Selection gates on this against
|
|
56
|
+
#: the batch actually in hand.
|
|
57
|
+
requires_trajectories: bool = False
|
|
58
|
+
#: Modality slots that must actually be FILLED in the batch for this analysis
|
|
59
|
+
#: to mean anything — the data-shape counterpart of ``applies_to_modalities``.
|
|
60
|
+
#:
|
|
61
|
+
#: ``applies_to_modalities`` is matched against what the MODEL declares, and
|
|
62
|
+
#: the match is an intersection, so one shared member is enough: an audio
|
|
63
|
+
#: model declares ``{"text", "audio"}`` and therefore matches every analyzer
|
|
64
|
+
#: carrying ``"text"``. That is the same hole ``requires_trajectories`` was
|
|
65
|
+
#: added to close, one modality over — the analyzer is selected, runs, reads
|
|
66
|
+
#: an empty slot, and reports a number computed over nothing.
|
|
67
|
+
#:
|
|
68
|
+
#: Empty (the default) means the analysis needs no particular slot filled.
|
|
69
|
+
#: Selection gates this against the slots the batch in hand actually fills.
|
|
70
|
+
requires_modalities: frozenset[str] = frozenset()
|
|
71
|
+
#: ``metric name -> what it measures, in one plain sentence``.
|
|
72
|
+
#:
|
|
73
|
+
#: The analyzer is the only thing that knows what its own numbers mean, and
|
|
74
|
+
#: without saying so every consumer has to guess from the identifier. The
|
|
75
|
+
#: dashboard guessed by replacing underscores with spaces, which is how a
|
|
76
|
+
#: chart came to be labelled "output chars (answer extraction audit)" and
|
|
77
|
+
#: how six different metrics all truncated to "Did the model g...".
|
|
78
|
+
#:
|
|
79
|
+
#: Write it for someone who has not read this file: say what the number
|
|
80
|
+
#: counts and which direction is bad. Undocumented metrics still work --
|
|
81
|
+
#: they are reported as undocumented rather than dressed up, so the gap is
|
|
82
|
+
#: visible instead of being filled with plausible-looking prose.
|
|
83
|
+
#:
|
|
84
|
+
#: A value may be the sentence alone, or ``(short_label, sentence)`` when the
|
|
85
|
+
#: metric needs a name a chart axis can hold — roughly 24 characters, since
|
|
86
|
+
#: a bar chart's label column is narrow and anything longer is truncated to
|
|
87
|
+
#: an ellipsis that identifies nothing. Without a short label the identifier
|
|
88
|
+
#: is humanized as the fallback, which reads as jargon but at least does not
|
|
89
|
+
#: pretend to be an explanation.
|
|
90
|
+
signal_docs: dict[str, "str | tuple[str, str]"] = {}
|
|
91
|
+
|
|
92
|
+
def __init__(self, **params: Any) -> None:
|
|
93
|
+
# Store hyper-parameters sklearn-style for introspection / reproduction.
|
|
94
|
+
self._params: dict[str, Any] = dict(params)
|
|
95
|
+
for key, value in params.items():
|
|
96
|
+
setattr(self, key, value)
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
# sklearn-style introspection
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def get_params(self) -> dict[str, Any]:
|
|
103
|
+
"""Return the analyzer's hyper-parameters.
|
|
104
|
+
|
|
105
|
+
Derived from the concrete subclass's ``__init__`` signature so that
|
|
106
|
+
typed subclasses never silently omit a parameter by forgetting to
|
|
107
|
+
forward it to ``super().__init__``.
|
|
108
|
+
"""
|
|
109
|
+
sig = inspect.signature(type(self).__init__)
|
|
110
|
+
typed_params = {
|
|
111
|
+
name
|
|
112
|
+
for name, p in sig.parameters.items()
|
|
113
|
+
if name != "self"
|
|
114
|
+
and p.kind not in (
|
|
115
|
+
inspect.Parameter.VAR_POSITIONAL,
|
|
116
|
+
inspect.Parameter.VAR_KEYWORD,
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
if typed_params:
|
|
120
|
+
return {name: getattr(self, name) for name in typed_params}
|
|
121
|
+
# No typed params — base **params pattern or zero-param analyzer.
|
|
122
|
+
return dict(self._params)
|
|
123
|
+
|
|
124
|
+
def set_params(self, **params: Any) -> "Analyzer":
|
|
125
|
+
"""Update hyper-parameters in place and return self (chainable)."""
|
|
126
|
+
self._params.update(params)
|
|
127
|
+
for key, value in params.items():
|
|
128
|
+
setattr(self, key, value)
|
|
129
|
+
return self
|
|
130
|
+
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
# Execution
|
|
133
|
+
# ------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
def run(self, model: "Model", data: Any) -> "Result":
|
|
136
|
+
"""Validate capabilities, normalise *data*, and run the analysis.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
model: Any :class:`~evalrx.core.model.Model`.
|
|
140
|
+
data: ``str | FailureCase | Inputs | CaseBatch | iterable`` — normalised
|
|
141
|
+
via :func:`~evalrx.core.case.as_casebatch`.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
A :class:`~evalrx.core.result.Result` (subclass) instance.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
CapabilityError: if *model* lacks a required capability.
|
|
148
|
+
"""
|
|
149
|
+
self._check_capabilities(model)
|
|
150
|
+
cases = as_casebatch(data)
|
|
151
|
+
return self._run(model, cases)
|
|
152
|
+
|
|
153
|
+
@abstractmethod
|
|
154
|
+
def _run(self, model: "Model", cases: CaseBatch) -> "Result":
|
|
155
|
+
"""Subclass hook: perform the analysis over an already-normalised batch."""
|
|
156
|
+
|
|
157
|
+
# ------------------------------------------------------------------
|
|
158
|
+
# Helpers
|
|
159
|
+
# ------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
def _check_capabilities(self, model: "Model") -> None:
|
|
162
|
+
if not self.requires: # capability-free (e.g. trajectory heuristics) — model may be None
|
|
163
|
+
return
|
|
164
|
+
missing = set(self.requires) - set(getattr(model, "capabilities", frozenset()))
|
|
165
|
+
if missing:
|
|
166
|
+
raise CapabilityError(
|
|
167
|
+
analyzer=self.name,
|
|
168
|
+
model=repr(model),
|
|
169
|
+
missing=missing,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def __repr__(self) -> str:
|
|
173
|
+
params = ", ".join(f"{k}={v!r}" for k, v in self.get_params().items())
|
|
174
|
+
return f"{type(self).__name__}({params})"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Capability vocabulary — the contract between models and analyzers.
|
|
2
|
+
|
|
3
|
+
A *model* declares the capabilities it **provides** (e.g. a local Qwen exposes
|
|
4
|
+
``ATTENTION`` and ``HIDDEN_STATES``; an API model provides only ``GENERATE``).
|
|
5
|
+
An *analyzer* declares the capabilities it **requires**. The registry matches
|
|
6
|
+
the two, so an agent can ask "what can I run on this model?" without touching
|
|
7
|
+
any model internals.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from enum import Enum
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Capability(str, Enum):
|
|
16
|
+
"""What a model can expose to analyzers.
|
|
17
|
+
|
|
18
|
+
Inherits from ``str`` so values are JSON-serialisable and print cleanly,
|
|
19
|
+
which matters when results are handed to an LLM agent.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
GENERATE = "generate" # produce text output
|
|
23
|
+
TOOL_CALLS = "tool_calls" # emit/consume tool calls (agent mode)
|
|
24
|
+
LOGPROBS = "logprobs" # top-k token logprobs (API-feasible; OpenAI yes / Gemini no)
|
|
25
|
+
LOGITS = "logits" # full next-token logit tensor (white-box forward)
|
|
26
|
+
ATTENTION = "attention" # per-layer, per-head attention weights
|
|
27
|
+
HIDDEN_STATES = "hidden_states" # per-layer residual-stream activations
|
|
28
|
+
EMBEDDINGS = "embeddings" # input/output embedding vectors
|
|
29
|
+
ACTIVATIONS = "activations" # arbitrary hooked module activations
|
|
30
|
+
GRADIENTS = "gradients" # input/parameter gradients (saliency)
|
|
31
|
+
|
|
32
|
+
def __repr__(self) -> str: # pragma: no cover - cosmetic
|
|
33
|
+
return f"Capability.{self.name}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CapabilityError(RuntimeError):
|
|
37
|
+
"""Raised when an analyzer is run on a model that lacks required capabilities."""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
analyzer: str,
|
|
42
|
+
model: str,
|
|
43
|
+
missing: set[Capability],
|
|
44
|
+
) -> None:
|
|
45
|
+
self.analyzer = analyzer
|
|
46
|
+
self.model = model
|
|
47
|
+
self.missing = missing
|
|
48
|
+
missing_names = sorted(c.value for c in missing)
|
|
49
|
+
super().__init__(
|
|
50
|
+
f"Analyzer '{analyzer}' cannot run on model '{model}': "
|
|
51
|
+
f"missing capabilities {missing_names}. "
|
|
52
|
+
f"This analysis requires a model that provides them "
|
|
53
|
+
f"(typically a white-box / locally-deployed model)."
|
|
54
|
+
)
|
evalrx/core/case.py
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"""FailureCase — the central data object of EvalRX.
|
|
2
|
+
|
|
3
|
+
Everything in the system speaks ``FailureCase``:
|
|
4
|
+
- datasets *produce* batches of cases,
|
|
5
|
+
- analyzers *attribute* failures over cases,
|
|
6
|
+
- stats *test* significance across cases,
|
|
7
|
+
- the agent *accumulates* and *evolves* a corpus of cases.
|
|
8
|
+
|
|
9
|
+
Analyzers accept ``str | FailureCase | list | CaseBatch`` and normalise via
|
|
10
|
+
:func:`as_casebatch`, so ``model.call_attention("a prompt")`` stays ergonomic
|
|
11
|
+
while the canonical unit of work remains the case.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import uuid
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from typing import Any, Iterable, Iterator, Optional
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Label(str, Enum):
|
|
23
|
+
"""Outcome label for a case."""
|
|
24
|
+
|
|
25
|
+
PASS = "pass"
|
|
26
|
+
FAIL = "fail"
|
|
27
|
+
UNKNOWN = "unknown"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Source(str, Enum):
|
|
31
|
+
"""Where a case came from — important for self-evolution provenance."""
|
|
32
|
+
|
|
33
|
+
HUMAN = "human"
|
|
34
|
+
DATASET = "dataset"
|
|
35
|
+
AGENT = "agent"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Provenance:
|
|
40
|
+
"""How a case was created."""
|
|
41
|
+
|
|
42
|
+
source: Source = Source.HUMAN
|
|
43
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class Inputs:
|
|
48
|
+
"""Model inputs for a case.
|
|
49
|
+
|
|
50
|
+
``prompt`` is always present; the multimodal slots are populated per the
|
|
51
|
+
model's modality set — ``image`` for VLMs, ``audio`` / ``video`` for omni
|
|
52
|
+
models (e.g. Qwen3-Omni). Each slot holds a decoded object (PIL.Image /
|
|
53
|
+
waveform / frames) **or** a path/URL the backend resolves.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
prompt: str
|
|
57
|
+
image: Any = None # PIL.Image | path/URL | None (VLM)
|
|
58
|
+
audio: Any = None # np.ndarray | path/URL | None (omni)
|
|
59
|
+
video: Any = None # frames | path/URL | None (omni)
|
|
60
|
+
|
|
61
|
+
def __str__(self) -> str:
|
|
62
|
+
return self.prompt
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ----------------------------------------------------------------------
|
|
66
|
+
# Agent / trajectory data model
|
|
67
|
+
# ----------------------------------------------------------------------
|
|
68
|
+
# A FailureCase can be a single (unit) interaction OR carry a multi-step agent
|
|
69
|
+
# trajectory. The trajectory schema below is the canonical substrate for both
|
|
70
|
+
# execution logging and agent failure analysis (loop detection, first-error
|
|
71
|
+
# attribution, recovery). It deliberately mirrors the keys an OTel/OpenInference
|
|
72
|
+
# span carries and an existing node-tree conversation already stores, so a real
|
|
73
|
+
# engine's trace adapts in via ``Trajectory.from_records`` without re-modelling.
|
|
74
|
+
|
|
75
|
+
class StepRole(str, Enum):
|
|
76
|
+
"""Role of a single trajectory step."""
|
|
77
|
+
|
|
78
|
+
USER = "user"
|
|
79
|
+
PLANNER = "planner"
|
|
80
|
+
ACTOR = "actor"
|
|
81
|
+
TOOL = "tool"
|
|
82
|
+
VERIFIER = "verifier"
|
|
83
|
+
MEMORY = "memory"
|
|
84
|
+
SYSTEM = "system"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _json_safe(value: Any) -> Any:
|
|
88
|
+
"""Best-effort conversion of *value* into something ``json.dumps`` accepts.
|
|
89
|
+
|
|
90
|
+
Rich objects degrade to descriptors instead of raising: images (anything
|
|
91
|
+
with ``.size`` + ``.mode``, i.e. PIL) become ``"<image WxH>"`` — trajectories
|
|
92
|
+
reference media, they never embed pixels.
|
|
93
|
+
"""
|
|
94
|
+
if value is None or isinstance(value, (bool, int, float, str)):
|
|
95
|
+
return value
|
|
96
|
+
if isinstance(value, Enum):
|
|
97
|
+
return value.value
|
|
98
|
+
if isinstance(value, dict):
|
|
99
|
+
return {str(k): _json_safe(v) for k, v in value.items()}
|
|
100
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
101
|
+
return [_json_safe(v) for v in value]
|
|
102
|
+
if hasattr(value, "size") and hasattr(value, "mode"): # PIL.Image, without importing PIL
|
|
103
|
+
try:
|
|
104
|
+
w, h = value.size
|
|
105
|
+
return f"<image {w}x{h}>"
|
|
106
|
+
except Exception: # pragma: no cover - exotic size attrs
|
|
107
|
+
return "<image>"
|
|
108
|
+
if hasattr(value, "to_dict"):
|
|
109
|
+
try:
|
|
110
|
+
return _json_safe(value.to_dict())
|
|
111
|
+
except Exception: # pragma: no cover - defensive
|
|
112
|
+
return str(value)
|
|
113
|
+
return str(value)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class Step:
|
|
118
|
+
"""One atomic step in an agent trajectory.
|
|
119
|
+
|
|
120
|
+
Execution fills the top block; analyzers WRITE the annotation block
|
|
121
|
+
(``is_first_error`` / ``failure_mode`` / ``judge_confidence``).
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
idx: int
|
|
125
|
+
role: StepRole = StepRole.ACTOR
|
|
126
|
+
content: Any = None # thought / message / final text
|
|
127
|
+
agent_id: str = "main" # which agent acted (multi-agent traces)
|
|
128
|
+
tool_call: Optional[dict] = None # {"name": ..., "args": {...}, "id": ...}
|
|
129
|
+
observation: Any = None # tool/env result (images by ref)
|
|
130
|
+
span: dict = field(default_factory=dict) # OTel-ish attrs: tokens, latency_ms, cost, model
|
|
131
|
+
# --- analysis annotations (written by analyzers, not execution) ---
|
|
132
|
+
is_first_error: Optional[bool] = None
|
|
133
|
+
failure_mode: Optional[str] = None # MAST code, e.g. "FM-2.4"
|
|
134
|
+
judge_confidence: Optional[float] = None
|
|
135
|
+
|
|
136
|
+
def to_dict(self) -> dict[str, Any]:
|
|
137
|
+
"""JSON-safe dict (images and rich objects degrade to descriptors)."""
|
|
138
|
+
return {
|
|
139
|
+
"idx": self.idx,
|
|
140
|
+
"role": self.role.value,
|
|
141
|
+
"content": _json_safe(self.content),
|
|
142
|
+
"agent_id": self.agent_id,
|
|
143
|
+
"tool_call": _json_safe(self.tool_call),
|
|
144
|
+
"observation": _json_safe(self.observation),
|
|
145
|
+
"span": _json_safe(self.span),
|
|
146
|
+
"is_first_error": self.is_first_error,
|
|
147
|
+
"failure_mode": self.failure_mode,
|
|
148
|
+
"judge_confidence": self.judge_confidence,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
@classmethod
|
|
152
|
+
def from_dict(cls, d: dict) -> "Step":
|
|
153
|
+
"""Inverse of :meth:`to_dict` (descriptor strings stay strings)."""
|
|
154
|
+
role = d.get("role", StepRole.ACTOR)
|
|
155
|
+
return cls(
|
|
156
|
+
idx=int(d.get("idx", 0)),
|
|
157
|
+
role=StepRole(role) if not isinstance(role, StepRole) else role,
|
|
158
|
+
content=d.get("content"),
|
|
159
|
+
agent_id=d.get("agent_id", "main"),
|
|
160
|
+
tool_call=d.get("tool_call"),
|
|
161
|
+
observation=d.get("observation"),
|
|
162
|
+
span=d.get("span") or {},
|
|
163
|
+
is_first_error=d.get("is_first_error"),
|
|
164
|
+
failure_mode=d.get("failure_mode"),
|
|
165
|
+
judge_confidence=d.get("judge_confidence"),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass
|
|
170
|
+
class Trajectory:
|
|
171
|
+
"""The full sequence of an agent run — the unit of trajectory analysis."""
|
|
172
|
+
|
|
173
|
+
sample_id: str
|
|
174
|
+
goal: str = ""
|
|
175
|
+
steps: list[Step] = field(default_factory=list)
|
|
176
|
+
final_answer: Any = None
|
|
177
|
+
ground_truth: Any = None
|
|
178
|
+
outcome: Label = Label.UNKNOWN # reuse the engine's outcome, don't recompute
|
|
179
|
+
metrics: dict[str, Any] = field(default_factory=dict) # n_steps, tool_cost, latency, tokens, recovery_rate
|
|
180
|
+
|
|
181
|
+
def __len__(self) -> int:
|
|
182
|
+
return len(self.steps)
|
|
183
|
+
|
|
184
|
+
def __iter__(self) -> Iterator[Step]:
|
|
185
|
+
return iter(self.steps)
|
|
186
|
+
|
|
187
|
+
def to_dict(self) -> dict[str, Any]:
|
|
188
|
+
"""JSON-safe dict — the on-disk trajectory format (``trajectory.json``)."""
|
|
189
|
+
return {
|
|
190
|
+
"sample_id": self.sample_id,
|
|
191
|
+
"goal": self.goal,
|
|
192
|
+
"steps": [s.to_dict() for s in self.steps],
|
|
193
|
+
"final_answer": _json_safe(self.final_answer),
|
|
194
|
+
"ground_truth": _json_safe(self.ground_truth),
|
|
195
|
+
"outcome": self.outcome.value,
|
|
196
|
+
"metrics": _json_safe(self.metrics),
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
@classmethod
|
|
200
|
+
def from_dict(cls, d: dict) -> "Trajectory":
|
|
201
|
+
"""Inverse of :meth:`to_dict` — reload a persisted trajectory."""
|
|
202
|
+
outcome = d.get("outcome", Label.UNKNOWN)
|
|
203
|
+
return cls(
|
|
204
|
+
sample_id=d.get("sample_id", ""),
|
|
205
|
+
goal=d.get("goal", ""),
|
|
206
|
+
steps=[Step.from_dict(s) for s in d.get("steps") or []],
|
|
207
|
+
final_answer=d.get("final_answer"),
|
|
208
|
+
ground_truth=d.get("ground_truth"),
|
|
209
|
+
outcome=Label(outcome) if not isinstance(outcome, Label) else outcome,
|
|
210
|
+
metrics=d.get("metrics") or {},
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
@classmethod
|
|
214
|
+
def from_records(
|
|
215
|
+
cls,
|
|
216
|
+
records: Iterable[dict],
|
|
217
|
+
*,
|
|
218
|
+
sample_id: str,
|
|
219
|
+
goal: str = "",
|
|
220
|
+
final_answer: Any = None,
|
|
221
|
+
ground_truth: Any = None,
|
|
222
|
+
outcome: Label = Label.UNKNOWN,
|
|
223
|
+
metrics: dict | None = None,
|
|
224
|
+
) -> "Trajectory":
|
|
225
|
+
"""Build a Trajectory from a list of plain dict step-records.
|
|
226
|
+
|
|
227
|
+
This is the seam for an external engine (e.g. the node-tree
|
|
228
|
+
``conversation_history`` of an existing API runner): map each turn to a
|
|
229
|
+
``{role, content, tool_call, observation, span, agent_id}`` dict and
|
|
230
|
+
pass them here. No engine internals leak into the analysis layer.
|
|
231
|
+
"""
|
|
232
|
+
steps: list[Step] = []
|
|
233
|
+
for i, rec in enumerate(records):
|
|
234
|
+
role = rec.get("role", StepRole.ACTOR)
|
|
235
|
+
steps.append(
|
|
236
|
+
Step(
|
|
237
|
+
idx=i,
|
|
238
|
+
role=StepRole(role) if not isinstance(role, StepRole) else role,
|
|
239
|
+
content=rec.get("content"),
|
|
240
|
+
agent_id=rec.get("agent_id", "main"),
|
|
241
|
+
tool_call=rec.get("tool_call"),
|
|
242
|
+
observation=rec.get("observation"),
|
|
243
|
+
span=rec.get("span", {}),
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
return cls(
|
|
247
|
+
sample_id=sample_id,
|
|
248
|
+
goal=goal,
|
|
249
|
+
steps=steps,
|
|
250
|
+
final_answer=final_answer,
|
|
251
|
+
ground_truth=ground_truth,
|
|
252
|
+
outcome=outcome,
|
|
253
|
+
metrics=metrics or {},
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@dataclass
|
|
258
|
+
class FailureCase:
|
|
259
|
+
"""A single unit of failure analysis.
|
|
260
|
+
|
|
261
|
+
Attributes:
|
|
262
|
+
inputs: What was fed to the model (:class:`Inputs`).
|
|
263
|
+
expected: Gold / expected behaviour, if known.
|
|
264
|
+
observed: What the model actually produced, if run.
|
|
265
|
+
label: :class:`Label` — pass / fail / unknown.
|
|
266
|
+
tags: Free-form failure-taxonomy tags (e.g. ``{"hallucination"}``).
|
|
267
|
+
provenance: How this case was created (:class:`Provenance`).
|
|
268
|
+
id: Stable identifier (auto-generated if omitted).
|
|
269
|
+
metadata: Free-form extra fields.
|
|
270
|
+
"""
|
|
271
|
+
|
|
272
|
+
inputs: Inputs
|
|
273
|
+
expected: Any = None
|
|
274
|
+
observed: Any = None
|
|
275
|
+
trajectory: Optional[Trajectory] = None # set for multi-step / agent cases
|
|
276
|
+
label: Label = Label.UNKNOWN
|
|
277
|
+
tags: set[str] = field(default_factory=set)
|
|
278
|
+
provenance: Provenance = field(default_factory=Provenance)
|
|
279
|
+
id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
|
|
280
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
281
|
+
|
|
282
|
+
@classmethod
|
|
283
|
+
def from_prompt(cls, prompt: str, **kwargs) -> "FailureCase":
|
|
284
|
+
"""Build a case from a raw prompt string."""
|
|
285
|
+
return cls(inputs=Inputs(prompt=prompt), **kwargs)
|
|
286
|
+
|
|
287
|
+
def to_dict(self) -> dict[str, Any]:
|
|
288
|
+
return {
|
|
289
|
+
"id": self.id,
|
|
290
|
+
"inputs": {
|
|
291
|
+
"prompt": self.inputs.prompt,
|
|
292
|
+
"image": _json_safe(self.inputs.image),
|
|
293
|
+
"audio": _json_safe(self.inputs.audio),
|
|
294
|
+
"video": _json_safe(self.inputs.video),
|
|
295
|
+
},
|
|
296
|
+
"expected": self.expected,
|
|
297
|
+
"observed": self.observed,
|
|
298
|
+
"trajectory": self.trajectory.to_dict() if self.trajectory else None,
|
|
299
|
+
"label": self.label.value,
|
|
300
|
+
"tags": sorted(self.tags),
|
|
301
|
+
"provenance": {
|
|
302
|
+
"source": self.provenance.source.value,
|
|
303
|
+
"metadata": self.provenance.metadata,
|
|
304
|
+
},
|
|
305
|
+
"metadata": self.metadata,
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
class CaseBatch:
|
|
310
|
+
"""An ordered collection of :class:`FailureCase` with convenience helpers."""
|
|
311
|
+
|
|
312
|
+
def __init__(self, cases: Iterable[FailureCase] | None = None) -> None:
|
|
313
|
+
self._cases: list[FailureCase] = list(cases) if cases else []
|
|
314
|
+
|
|
315
|
+
# -- constructors --------------------------------------------------
|
|
316
|
+
@classmethod
|
|
317
|
+
def from_prompts(cls, prompts: Iterable[str], **kwargs) -> "CaseBatch":
|
|
318
|
+
"""Build a batch from raw prompt strings."""
|
|
319
|
+
return cls(FailureCase.from_prompt(p, **kwargs) for p in prompts)
|
|
320
|
+
|
|
321
|
+
# -- list-like behaviour -------------------------------------------
|
|
322
|
+
def __iter__(self) -> Iterator[FailureCase]:
|
|
323
|
+
return iter(self._cases)
|
|
324
|
+
|
|
325
|
+
def __len__(self) -> int:
|
|
326
|
+
return len(self._cases)
|
|
327
|
+
|
|
328
|
+
def __getitem__(self, idx: int) -> FailureCase:
|
|
329
|
+
return self._cases[idx]
|
|
330
|
+
|
|
331
|
+
def append(self, case: FailureCase) -> None:
|
|
332
|
+
self._cases.append(case)
|
|
333
|
+
|
|
334
|
+
# -- querying (used by the agent / stats) --------------------------
|
|
335
|
+
def filter(
|
|
336
|
+
self,
|
|
337
|
+
label: Label | None = None,
|
|
338
|
+
tags: set[str] | None = None,
|
|
339
|
+
) -> "CaseBatch":
|
|
340
|
+
"""Return a new batch matching *label* and/or containing all *tags*."""
|
|
341
|
+
out = [
|
|
342
|
+
c
|
|
343
|
+
for c in self._cases
|
|
344
|
+
if (label is None or c.label == label)
|
|
345
|
+
and (tags is None or tags.issubset(c.tags))
|
|
346
|
+
]
|
|
347
|
+
return CaseBatch(out)
|
|
348
|
+
|
|
349
|
+
def stratified_head(self, n: int) -> "list[FailureCase]":
|
|
350
|
+
"""First *n* cases, but label-balanced: take FAIL and PASS in document
|
|
351
|
+
order and interleave so a capped subsample keeps minority-class (FAIL)
|
|
352
|
+
representation instead of whatever happens to sit at the head.
|
|
353
|
+
|
|
354
|
+
White-box analyzers cap how many cases they probe (one forward each);
|
|
355
|
+
on an enriched/curated batch a plain ``[:n]`` head is mostly PASS, which
|
|
356
|
+
starves the FAIL group and leaves downstream group contrasts
|
|
357
|
+
underpowered. Returns all cases unchanged when ``n >= len`` or ``n<=0``.
|
|
358
|
+
"""
|
|
359
|
+
if n <= 0 or n >= len(self._cases):
|
|
360
|
+
return list(self._cases)
|
|
361
|
+
fails = [c for c in self._cases if c.label == Label.FAIL]
|
|
362
|
+
rest = [c for c in self._cases if c.label != Label.FAIL]
|
|
363
|
+
# Aim for a balanced split, but never drop available minority cases below
|
|
364
|
+
# what fits: give FAIL up to half the budget, fill the rest with others.
|
|
365
|
+
n_fail = min(len(fails), max(n // 2, n - len(rest)))
|
|
366
|
+
keep = fails[:n_fail] + rest[: n - n_fail]
|
|
367
|
+
# preserve original document order among the kept cases
|
|
368
|
+
kept = set(id(c) for c in keep)
|
|
369
|
+
return [c for c in self._cases if id(c) in kept]
|
|
370
|
+
|
|
371
|
+
def __repr__(self) -> str:
|
|
372
|
+
return f"CaseBatch(n={len(self)})"
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
#: Modality slots other than text. Mirrors ``contract.common.MEDIA_SLOTS``;
|
|
376
|
+
#: duplicated rather than imported so ``core`` keeps no dependency on the
|
|
377
|
+
#: optional ``contract`` extra (pydantic).
|
|
378
|
+
MEDIA_SLOTS: tuple[str, ...] = ("image", "audio", "video")
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def probed_modalities(data: Any) -> set[str]:
|
|
382
|
+
"""Modality slots the cases in *data* actually fill.
|
|
383
|
+
|
|
384
|
+
This is what a batch IS, not what the model CAN DO, and routing must use
|
|
385
|
+
this one: an omni model (text+image+audio+video) evaluated on an audio
|
|
386
|
+
benchmark declares image as well, so routing on the model's declaration
|
|
387
|
+
sends image analyzers at a batch holding no images — they match, run, and
|
|
388
|
+
report a number computed over nothing.
|
|
389
|
+
|
|
390
|
+
Returns ``{"text"}`` for a batch that fills no media slot; a prompt is always
|
|
391
|
+
present, so text is the floor rather than a slot. Non-iterable or unreadable
|
|
392
|
+
*data* also yields ``{"text"}`` — an unknown shape must not be reported as
|
|
393
|
+
evidence that a slot is absent.
|
|
394
|
+
"""
|
|
395
|
+
present: set[str] = {"text"}
|
|
396
|
+
try:
|
|
397
|
+
cases = list(data) if data is not None else []
|
|
398
|
+
except TypeError:
|
|
399
|
+
return present
|
|
400
|
+
for case in cases:
|
|
401
|
+
inputs = getattr(case, "inputs", None)
|
|
402
|
+
if inputs is None:
|
|
403
|
+
continue
|
|
404
|
+
for slot in MEDIA_SLOTS:
|
|
405
|
+
if getattr(inputs, slot, None) is not None:
|
|
406
|
+
present.add(slot)
|
|
407
|
+
return present
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def as_casebatch(
|
|
411
|
+
data: str | FailureCase | Inputs | Trajectory | Iterable | CaseBatch,
|
|
412
|
+
) -> CaseBatch:
|
|
413
|
+
"""Normalise common inputs into a :class:`CaseBatch`.
|
|
414
|
+
|
|
415
|
+
Accepts:
|
|
416
|
+
- a ``str`` → single-case batch from the prompt,
|
|
417
|
+
- a :class:`FailureCase` → single-case batch,
|
|
418
|
+
- an :class:`Inputs` → single-case batch,
|
|
419
|
+
- a :class:`Trajectory` → single-case batch,
|
|
420
|
+
- a :class:`CaseBatch` → returned unchanged,
|
|
421
|
+
- any iterable of the above.
|
|
422
|
+
"""
|
|
423
|
+
if isinstance(data, CaseBatch):
|
|
424
|
+
return data
|
|
425
|
+
if isinstance(data, str):
|
|
426
|
+
return CaseBatch([FailureCase.from_prompt(data)])
|
|
427
|
+
if isinstance(data, FailureCase):
|
|
428
|
+
return CaseBatch([data])
|
|
429
|
+
if isinstance(data, Inputs):
|
|
430
|
+
return CaseBatch([FailureCase(inputs=data)])
|
|
431
|
+
if isinstance(data, Trajectory): # agent analyzers accept a Trajectory directly
|
|
432
|
+
return CaseBatch([FailureCase(inputs=Inputs(prompt=data.goal), trajectory=data)])
|
|
433
|
+
if isinstance(data, Iterable):
|
|
434
|
+
batch = CaseBatch()
|
|
435
|
+
for item in data:
|
|
436
|
+
# flatten via single-item normalisation
|
|
437
|
+
for case in as_casebatch(item):
|
|
438
|
+
batch.append(case)
|
|
439
|
+
return batch
|
|
440
|
+
raise TypeError(
|
|
441
|
+
f"Cannot interpret {type(data).__name__} as cases. "
|
|
442
|
+
"Pass a str, FailureCase, Inputs, Trajectory, CaseBatch, or an iterable of these."
|
|
443
|
+
)
|