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