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,102 @@
|
|
|
1
|
+
"""VL-SHAP — Shapley attribution over image regions for VLMs.
|
|
2
|
+
|
|
3
|
+
Black-box (``LOGPROBS``): partition the image into a grid of regions, mask
|
|
4
|
+
subsets, score each masked image by the model's output logprob, and compute each
|
|
5
|
+
region's Shapley value → a spatial attribution map over the image.
|
|
6
|
+
|
|
7
|
+
The region scorer is injectable (``region_score_fn(kept_regions: set) -> float``)
|
|
8
|
+
for decoupled testing; the default builds a grid-masked image (PIL) and scores it
|
|
9
|
+
via ``model.logprobs`` (mean logprob). ``requires=LOGPROBS``; VLM-only.
|
|
10
|
+
|
|
11
|
+
Shapley values for ML:
|
|
12
|
+
"A Unified Approach to Interpreting Model Predictions"
|
|
13
|
+
Lundberg & Lee, NeurIPS 2017 — https://arxiv.org/abs/1705.07874
|
|
14
|
+
Code: https://github.com/shap/shap
|
|
15
|
+
|
|
16
|
+
Region-level visual attribution for VLMs (MM-SHAP framework):
|
|
17
|
+
"MM-SHAP: A Performance-agnostic Metric for Measuring Multimodal Contributions
|
|
18
|
+
in Vision and Language Models"
|
|
19
|
+
Parcalabescu & Frank, ACL 2022 — https://arxiv.org/abs/2212.08158
|
|
20
|
+
Code: https://github.com/coastalcph/mm-shap
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import math
|
|
26
|
+
from typing import TYPE_CHECKING, Callable, Optional
|
|
27
|
+
|
|
28
|
+
from evalrx.analyzers.perturbation._shapley import shapley_values
|
|
29
|
+
from evalrx.core.analyzer import Analyzer
|
|
30
|
+
from evalrx.core.capability import Capability
|
|
31
|
+
from evalrx.core.case import Inputs
|
|
32
|
+
from evalrx.core.registry import register_analyzer
|
|
33
|
+
from evalrx.core.result import Result
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from evalrx.core.case import CaseBatch
|
|
37
|
+
from evalrx.core.model import Model
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@register_analyzer("vl_shap")
|
|
41
|
+
class VLShapAnalyzer(Analyzer):
|
|
42
|
+
"""Per-region Shapley attribution over a grid of image regions."""
|
|
43
|
+
|
|
44
|
+
name = "vl_shap"
|
|
45
|
+
requires = frozenset({Capability.LOGPROBS})
|
|
46
|
+
applies_to_modalities = frozenset({"image"})
|
|
47
|
+
# Its default scorer dereferences case.inputs.image, so an omni model on an
|
|
48
|
+
# audio batch matched here and then raised on the first case.
|
|
49
|
+
requires_modalities = frozenset({"image"})
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
n_regions: int = 16,
|
|
54
|
+
region_score_fn: Optional[Callable[[set], float]] = None,
|
|
55
|
+
n_samples: int = 64,
|
|
56
|
+
top_k: int = 5,
|
|
57
|
+
seed: int = 0,
|
|
58
|
+
) -> None:
|
|
59
|
+
super().__init__(n_regions=n_regions, region_score_fn=region_score_fn,
|
|
60
|
+
n_samples=n_samples, top_k=top_k, seed=seed)
|
|
61
|
+
|
|
62
|
+
def _default_region_value(self, model: "Model", case) -> Callable[[set], float]:
|
|
63
|
+
from PIL import Image # lazy
|
|
64
|
+
|
|
65
|
+
image = case.inputs.image
|
|
66
|
+
if image is None:
|
|
67
|
+
raise ValueError("VLShapAnalyzer default scorer needs case.inputs.image (a PIL image).")
|
|
68
|
+
side = max(1, int(math.isqrt(self.n_regions)))
|
|
69
|
+
W, H = image.size
|
|
70
|
+
cw, ch = W / side, H / side
|
|
71
|
+
|
|
72
|
+
def value(kept: set) -> float:
|
|
73
|
+
masked = image.copy()
|
|
74
|
+
black = Image.new(image.mode, (max(1, int(cw)), max(1, int(ch))), 0)
|
|
75
|
+
for r in range(self.n_regions):
|
|
76
|
+
if r in kept:
|
|
77
|
+
continue
|
|
78
|
+
gx, gy = r % side, r // side
|
|
79
|
+
masked.paste(black, (int(gx * cw), int(gy * ch)))
|
|
80
|
+
lps = model.logprobs(Inputs(prompt=case.inputs.prompt, image=masked))
|
|
81
|
+
return sum(t.logprob for t in lps) / len(lps) if lps else 0.0
|
|
82
|
+
return value
|
|
83
|
+
|
|
84
|
+
def _run(self, model: "Model", cases: "CaseBatch") -> Result:
|
|
85
|
+
case = cases[0]
|
|
86
|
+
players = list(range(self.n_regions))
|
|
87
|
+
value = self.region_score_fn or self._default_region_value(model, case)
|
|
88
|
+
shap = shapley_values(players, value, n_samples=self.n_samples, seed=self.seed)
|
|
89
|
+
top = sorted(
|
|
90
|
+
({"region": r, "shapley": round(shap[r], 4)} for r in players),
|
|
91
|
+
key=lambda d: -abs(d["shapley"]),
|
|
92
|
+
)[: self.top_k]
|
|
93
|
+
return Result(
|
|
94
|
+
analyzer=self.name, model=repr(model), cases=cases,
|
|
95
|
+
artifacts={"region_shapley": shap},
|
|
96
|
+
findings={
|
|
97
|
+
"n_regions": self.n_regions,
|
|
98
|
+
"grid_side": max(1, int(math.isqrt(self.n_regions))),
|
|
99
|
+
"top_regions": top,
|
|
100
|
+
"total_abs_attribution": round(sum(abs(v) for v in shap.values()), 4),
|
|
101
|
+
},
|
|
102
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Reasoning analyzers — text-chain diagnostics (black-box, ``GENERATE`` only).
|
|
2
|
+
|
|
3
|
+
Two of these are HYGIENE probes that belong before the rest, not beside them:
|
|
4
|
+
``answer_extraction_audit`` (is the FAIL label real, or did the grader miss the
|
|
5
|
+
answer?) and ``termination_audit`` (did the generation stop cleanly, or hit the
|
|
6
|
+
token budget / a repetition loop?). Both produce confounds that mimic every
|
|
7
|
+
other probe's "bad" column, so a mechanism finding read before them is not
|
|
8
|
+
interpretable.
|
|
9
|
+
|
|
10
|
+
The rest decompose *how* a chain failed: ``arith_audit`` (computation slip vs
|
|
11
|
+
chain break, at zero extra cost), ``self_repair`` (detect / correct / damage),
|
|
12
|
+
``step_rollout_value`` (where the chain broke, by Math-Shepherd rollouts),
|
|
13
|
+
``knowledge_reasoning_split`` (missing fact vs broken composition), and
|
|
14
|
+
``contamination_score`` (is the benchmark measuring recall?).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from evalrx.analyzers.reasoning.answer_extraction_audit import AnswerExtractionAudit
|
|
18
|
+
from evalrx.analyzers.reasoning.arith_audit import ArithmeticAudit
|
|
19
|
+
from evalrx.analyzers.reasoning.contamination import ContaminationProbe
|
|
20
|
+
from evalrx.analyzers.reasoning.knowledge_split import KnowledgeReasoningSplit
|
|
21
|
+
from evalrx.analyzers.reasoning.self_repair import SelfRepairAnalyzer
|
|
22
|
+
from evalrx.analyzers.reasoning.step_rollout_value import StepRolloutValueAnalyzer
|
|
23
|
+
from evalrx.analyzers.reasoning.termination_audit import TerminationAudit
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"AnswerExtractionAudit",
|
|
27
|
+
"TerminationAudit",
|
|
28
|
+
"ArithmeticAudit",
|
|
29
|
+
"SelfRepairAnalyzer",
|
|
30
|
+
"StepRolloutValueAnalyzer",
|
|
31
|
+
"KnowledgeReasoningSplit",
|
|
32
|
+
"ContaminationProbe",
|
|
33
|
+
]
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Shared answer-handling helpers for the reasoning probes.
|
|
2
|
+
|
|
3
|
+
Every reasoning probe has to answer the same three questions — *what did the
|
|
4
|
+
model finally claim?*, *is that the gold answer?*, and *did the generation even
|
|
5
|
+
terminate?* — so the conventions live here once instead of drifting per probe.
|
|
6
|
+
|
|
7
|
+
Extraction follows the self-consistency / GSM8K convention (last ``Answer:`` tag
|
|
8
|
+
or ``\\boxed{}``, else the last non-empty line), and grading is numeric-aware:
|
|
9
|
+
for a numeric gold we compare against the LAST number in the prediction, which
|
|
10
|
+
is what "the answer is 18" means and what plain substring matching gets wrong
|
|
11
|
+
("18" is inside "180").
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import ast
|
|
17
|
+
import re
|
|
18
|
+
from typing import TYPE_CHECKING, Any, Optional
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from evalrx.core.case import FailureCase
|
|
22
|
+
|
|
23
|
+
#: The separator is MANDATORY (":", "=", or the word "is"). With it optional the
|
|
24
|
+
#: bare word "answer" matches — "the answer to this is unclear" would extract
|
|
25
|
+
#: "to this is unclear" — and since extraction takes the LAST hit, one trailing
|
|
26
|
+
#: "I hope this answer helps" silently replaces a correct tagged answer.
|
|
27
|
+
_ANSWER_TAG = re.compile(
|
|
28
|
+
r"(?:final\s+answer|answer)\s*(?:[:=]|\bis\b)\s*(.+)", re.IGNORECASE
|
|
29
|
+
)
|
|
30
|
+
_BOXED = re.compile(r"\\boxed\s*\{([^{}]*)\}")
|
|
31
|
+
_NUMBER = re.compile(r"-?\d[\d,]*(?:\.\d+)?")
|
|
32
|
+
_GIVE_UP = re.compile(
|
|
33
|
+
r"(i (?:can(?:no|')t|am unable|do not know|don't know)"
|
|
34
|
+
r"|cannot (?:be )?determine[d]?|not enough (?:information|context)"
|
|
35
|
+
r"|insufficient (?:information|data)|unable to (?:answer|solve|determine)"
|
|
36
|
+
r"|无法(?:确定|回答|判断)|不知道)",
|
|
37
|
+
re.IGNORECASE,
|
|
38
|
+
)
|
|
39
|
+
#: Sentence/clause terminators that mark a *clean* end of generation.
|
|
40
|
+
_TERMINATORS = ".!?\"'`)]}\u3002\uff01\uff1f"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
#: A span that is only the format placeholder the prompt asked for — models
|
|
44
|
+
#: restate "give it as 'Answer: <answer>'" and that echo, being LAST, would
|
|
45
|
+
#: otherwise replace the real answer.
|
|
46
|
+
_PLACEHOLDER = re.compile(r"^[<\[{(]\s*\w*\s*[>\]})]?['\".\s]*$")
|
|
47
|
+
|
|
48
|
+
#: A bare option label — ``(A)``, ``[B]``, ``C.`` — is a REAL multiple-choice
|
|
49
|
+
#: answer, but it is character-for-character the shape :data:`_PLACEHOLDER`
|
|
50
|
+
#: describes, so it needs an explicit exemption. Without one the guard throws
|
|
51
|
+
#: the answer away and extraction keeps walking backwards into the chain-of-
|
|
52
|
+
#: thought, where it picks up whatever prose came before. Measured on BBH
|
|
53
|
+
#: ``tracking_shuffled_objects_seven_objects`` / Qwen3.5-9B: 244 of 250 final
|
|
54
|
+
#: claims are a bare ``(X)``, 98 correct answers were scored FAIL, and the slice
|
|
55
|
+
#: read 0.592 instead of 0.984 — a mid-band dataset invented out of a saturated
|
|
56
|
+
#: one. A one-letter *placeholder* (``<X>``) loses to a one-letter answer here
|
|
57
|
+
#: on purpose: the letter is a valid option either way, so the collision costs
|
|
58
|
+
#: nothing, while the reverse costs the whole measurement.
|
|
59
|
+
_OPTION_LABEL = re.compile(r"^[<\[{(]?\s*[A-Za-z]\s*[>\]})]?[.\s]*$")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _is_placeholder(span: str) -> bool:
|
|
63
|
+
"""True when *span* is only the prompt's format hint echoed back."""
|
|
64
|
+
return bool(_PLACEHOLDER.match(span)) and not _OPTION_LABEL.match(span)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def extract_answer(text: Any) -> str:
|
|
68
|
+
r"""Last usable ``\boxed{}`` or ``Answer:``-tagged span, else the last non-empty line."""
|
|
69
|
+
raw = str(text or "")
|
|
70
|
+
# "Last usable" is by POSITION IN THE TEXT, across both conventions. Draining
|
|
71
|
+
# every \boxed{} before looking at a single "Answer:" tag made an INTERMEDIATE
|
|
72
|
+
# box outrank the final answer line whenever a chain boxed its working — on
|
|
73
|
+
# minervamath / Qwen3.5-9B that mis-scored 14 of 272 (gold 2.45e6, the model
|
|
74
|
+
# closed with "Answer: 2.45e6", extraction returned a mid-chain 7.353e14).
|
|
75
|
+
candidates: list[tuple[int, str]] = []
|
|
76
|
+
for match in _BOXED.finditer(raw):
|
|
77
|
+
span = match.group(1).strip()
|
|
78
|
+
if span:
|
|
79
|
+
candidates.append((match.end(), span))
|
|
80
|
+
for match in _ANSWER_TAG.finditer(raw):
|
|
81
|
+
# the tag regex is line-greedy; keep only the first line of the span
|
|
82
|
+
span = match.group(1)
|
|
83
|
+
candidate = span.splitlines()[0].strip() if span.splitlines() else ""
|
|
84
|
+
if candidate and not _is_placeholder(candidate):
|
|
85
|
+
candidates.append((match.end(), candidate))
|
|
86
|
+
if candidates:
|
|
87
|
+
return max(candidates, key=lambda item: item[0])[1]
|
|
88
|
+
lines = [line.strip() for line in raw.splitlines() if line.strip()]
|
|
89
|
+
return lines[-1] if lines else ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def normalize_answer(value: Any) -> str:
|
|
93
|
+
"""Lowercase, drop thousands separators and decoration, collapse whitespace."""
|
|
94
|
+
text = str(value if value is not None else "").lower().strip()
|
|
95
|
+
text = re.sub(r"(?<=\d),(?=\d{3}\b)", "", text) # 1,234 -> 1234
|
|
96
|
+
text = re.sub(r"[$€£%\\]|\*\*|\bthe\b", " ", text)
|
|
97
|
+
text = re.sub(r"[^\w\s./:+-]", " ", text, flags=re.UNICODE)
|
|
98
|
+
return " ".join(text.split()).strip(" .")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
#: Binary-answer vocabulary. true/false fold onto yes/no so a True/False task
|
|
102
|
+
#: (BBH boolean_expressions, web_of_lies) gets the same direction columns as a
|
|
103
|
+
#: Yes/No one.
|
|
104
|
+
_BINARY_WORDS = {"yes": "yes", "no": "no", "true": "yes", "false": "no"}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def binary_direction(text: Any, *, last: bool = False) -> Optional[str]:
|
|
108
|
+
"""``"yes"`` / ``"no"`` read off a binary answer, else ``None``.
|
|
109
|
+
|
|
110
|
+
Whole words only (``"not"``/``"none"`` never count as ``"no"``), decided
|
|
111
|
+
on the opening six words -- or, with ``last=True``, the closing six, for a
|
|
112
|
+
tail window where the verdict comes after the reasoning. ``true``/``false``
|
|
113
|
+
map onto ``yes``/``no`` (see :data:`_BINARY_WORDS`).
|
|
114
|
+
"""
|
|
115
|
+
words = re.findall(r"[a-z]+", str(text or "").lower())
|
|
116
|
+
seq = words[-6:][::-1] if last else words[:6]
|
|
117
|
+
for word in seq:
|
|
118
|
+
if word in _BINARY_WORDS:
|
|
119
|
+
return _BINARY_WORDS[word]
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def binary_gold(expected: Any) -> Optional[str]:
|
|
124
|
+
"""The gold's direction when the task is yes/no (true/false), else ``None``.
|
|
125
|
+
|
|
126
|
+
Accepts a string, a one-element list/tuple of aliases, or an
|
|
127
|
+
``{"all_of": [...]}`` spec with a single entry. Anything longer than a bare
|
|
128
|
+
yes/no/true/false token is NOT a binary gold (``"yes, it is"`` -> ``None``),
|
|
129
|
+
so free-form tasks never grow direction columns by accident.
|
|
130
|
+
"""
|
|
131
|
+
if isinstance(expected, dict):
|
|
132
|
+
values = expected.get("all_of") or []
|
|
133
|
+
expected = values[0] if len(values) == 1 else None
|
|
134
|
+
if isinstance(expected, (list, tuple)):
|
|
135
|
+
expected = expected[0] if len(expected) == 1 else None
|
|
136
|
+
if expected is None:
|
|
137
|
+
return None
|
|
138
|
+
return _BINARY_WORDS.get(normalize_answer(expected))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def as_number(value: Any) -> Optional[float]:
|
|
142
|
+
"""Parse *value* as a number if it is one **in its entirety**, else ``None``."""
|
|
143
|
+
text = normalize_answer(value)
|
|
144
|
+
if not text:
|
|
145
|
+
return None
|
|
146
|
+
match = _NUMBER.fullmatch(text.replace(" ", ""))
|
|
147
|
+
if not match:
|
|
148
|
+
return None
|
|
149
|
+
try:
|
|
150
|
+
return float(match.group(0).replace(",", ""))
|
|
151
|
+
except ValueError: # pragma: no cover - regex already constrains this
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def numbers_in(text: Any) -> list[float]:
|
|
156
|
+
"""Every number appearing in *text*, in order."""
|
|
157
|
+
out: list[float] = []
|
|
158
|
+
for token in _NUMBER.findall(str(text or "")):
|
|
159
|
+
try:
|
|
160
|
+
out.append(float(token.replace(",", "")))
|
|
161
|
+
except ValueError: # pragma: no cover - defensive
|
|
162
|
+
continue
|
|
163
|
+
return out
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def answer_equal(prediction: Any, gold: Any, rel_tol: float = 1e-6) -> bool:
|
|
167
|
+
"""Numeric-aware answer equality.
|
|
168
|
+
|
|
169
|
+
A numeric gold is matched against the LAST number in the prediction (the
|
|
170
|
+
"the answer is 18" convention); short non-numeric golds — MC letters, yes/no
|
|
171
|
+
— need a standalone token match so that ``"b"`` does not match "probably".
|
|
172
|
+
"""
|
|
173
|
+
if isinstance(gold, (list, tuple, set, frozenset)):
|
|
174
|
+
return any(answer_equal(prediction, item, rel_tol=rel_tol) for item in gold)
|
|
175
|
+
pred = normalize_answer(prediction)
|
|
176
|
+
exp = normalize_answer(gold)
|
|
177
|
+
if not exp:
|
|
178
|
+
return False
|
|
179
|
+
if pred == exp:
|
|
180
|
+
return True
|
|
181
|
+
gold_num = as_number(exp)
|
|
182
|
+
if gold_num is not None:
|
|
183
|
+
found = numbers_in(pred)
|
|
184
|
+
if not found:
|
|
185
|
+
return False
|
|
186
|
+
# Both ends, because the prediction reaches here in two shapes: a tagged
|
|
187
|
+
# span that STARTS with the answer and may trail commentary ("620, since
|
|
188
|
+
# the broken beds don't count") — first number — and a bare sentence
|
|
189
|
+
# ending in it ("the answer is 18") — last number. Checking only one end
|
|
190
|
+
# scores the commentary's numbers on half the cases.
|
|
191
|
+
return any(
|
|
192
|
+
abs(candidate - gold_num) <= rel_tol * max(1.0, abs(gold_num))
|
|
193
|
+
for candidate in (found[0], found[-1])
|
|
194
|
+
)
|
|
195
|
+
if len(exp) <= 3:
|
|
196
|
+
return re.search(rf"(?<!\w){re.escape(exp)}(?!\w)", pred) is not None
|
|
197
|
+
return exp in pred
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def default_grader(prediction: Any, case: "FailureCase") -> Optional[bool]:
|
|
201
|
+
"""Grade *prediction* against ``case.expected`` (``None`` = ungradable)."""
|
|
202
|
+
if case.expected is None:
|
|
203
|
+
return None
|
|
204
|
+
tolerance = float((getattr(case, "metadata", {}) or {}).get("numeric_tolerance", 1e-6))
|
|
205
|
+
return answer_equal(extract_answer(prediction), case.expected, rel_tol=tolerance)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ----------------------------------------------------------------------
|
|
209
|
+
# Generation-shape helpers (termination / degeneration)
|
|
210
|
+
# ----------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
def word_ngrams(text: Any, n: int) -> list[tuple[str, ...]]:
|
|
213
|
+
words = str(text or "").lower().split()
|
|
214
|
+
if len(words) < n:
|
|
215
|
+
return []
|
|
216
|
+
return [tuple(words[i : i + n]) for i in range(len(words) - n + 1)]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def repetition_score(text: Any, n: int = 8) -> float:
|
|
220
|
+
"""Fraction of word ``n``-grams that are repeats — 0 for clean text, →1 for a loop.
|
|
221
|
+
|
|
222
|
+
Degenerate repetition is the failure mode that makes a truncated generation
|
|
223
|
+
look like a reasoning failure, so it gets its own column rather than being
|
|
224
|
+
folded into a length heuristic.
|
|
225
|
+
"""
|
|
226
|
+
grams = word_ngrams(text, n)
|
|
227
|
+
if not grams:
|
|
228
|
+
return 0.0
|
|
229
|
+
return round(1.0 - len(set(grams)) / len(grams), 4)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def has_answer_tag(text: Any) -> bool:
|
|
233
|
+
raw = str(text or "")
|
|
234
|
+
return bool(_BOXED.search(raw) or _ANSWER_TAG.search(raw))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def looks_truncated(text: Any) -> bool:
|
|
238
|
+
"""True when the text stops mid-thought: no terminator and no answer tag."""
|
|
239
|
+
stripped = str(text or "").rstrip()
|
|
240
|
+
if not stripped:
|
|
241
|
+
return False
|
|
242
|
+
return stripped[-1] not in _TERMINATORS and not has_answer_tag(stripped)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def looks_like_give_up(text: Any) -> bool:
|
|
246
|
+
return bool(_GIVE_UP.search(str(text or "")))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ----------------------------------------------------------------------
|
|
250
|
+
# Arithmetic verification
|
|
251
|
+
# ----------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
_ARITH_OPS = {
|
|
254
|
+
ast.Add: lambda a, b: a + b,
|
|
255
|
+
ast.Sub: lambda a, b: a - b,
|
|
256
|
+
ast.Mult: lambda a, b: a * b,
|
|
257
|
+
ast.Div: lambda a, b: a / b,
|
|
258
|
+
ast.Pow: lambda a, b: a**b,
|
|
259
|
+
}
|
|
260
|
+
#: ``lhs = rhs`` where the lhs is a pure-number expression carrying an operator.
|
|
261
|
+
_EQUATION = re.compile(
|
|
262
|
+
r"(?<![\w=])((?:-?\d[\d,]*(?:\.\d+)?|[+\-*/×÷^()\s]){3,}?)\s*=\s*"
|
|
263
|
+
# the number regex already absorbs a decimal tail, so a trailing '.' here is
|
|
264
|
+
# sentence punctuation ("= 41.") and must not block the match
|
|
265
|
+
r"(-?\d[\d,]*(?:\.\d+)?)(?!\.?\d)"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def safe_eval_arithmetic(expression: str) -> Optional[float]:
|
|
270
|
+
"""Evaluate a pure-number arithmetic expression, or ``None`` if it is not one.
|
|
271
|
+
|
|
272
|
+
Parsed with :mod:`ast` and walked against an allow-list — model output is
|
|
273
|
+
untrusted text and must never reach :func:`eval`.
|
|
274
|
+
"""
|
|
275
|
+
cleaned = (
|
|
276
|
+
expression.replace("×", "*").replace("÷", "/").replace("^", "**").strip()
|
|
277
|
+
)
|
|
278
|
+
cleaned = re.sub(r"(?<=\d),(?=\d{3}\b)", "", cleaned)
|
|
279
|
+
if not cleaned or not re.fullmatch(r"[\d.+\-*/()\s]+", cleaned):
|
|
280
|
+
return None
|
|
281
|
+
try:
|
|
282
|
+
tree = ast.parse(cleaned, mode="eval")
|
|
283
|
+
except SyntaxError:
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
def _eval(node: ast.AST) -> Optional[float]:
|
|
287
|
+
if isinstance(node, ast.Expression):
|
|
288
|
+
return _eval(node.body)
|
|
289
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
|
|
290
|
+
return float(node.value)
|
|
291
|
+
if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)):
|
|
292
|
+
inner = _eval(node.operand)
|
|
293
|
+
if inner is None:
|
|
294
|
+
return None
|
|
295
|
+
return -inner if isinstance(node.op, ast.USub) else inner
|
|
296
|
+
if isinstance(node, ast.BinOp) and type(node.op) in _ARITH_OPS:
|
|
297
|
+
left, right = _eval(node.left), _eval(node.right)
|
|
298
|
+
if left is None or right is None:
|
|
299
|
+
return None
|
|
300
|
+
try:
|
|
301
|
+
return float(_ARITH_OPS[type(node.op)](left, right))
|
|
302
|
+
except (ZeroDivisionError, OverflowError, ValueError):
|
|
303
|
+
return None
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
return _eval(tree)
|
|
308
|
+
except RecursionError: # pragma: no cover - pathological nesting
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def find_equations(text: Any) -> list[tuple[str, float, float]]:
|
|
313
|
+
"""Every ``expr = value`` statement as ``(expr, stated_value, computed_value)``.
|
|
314
|
+
|
|
315
|
+
Only statements whose left-hand side is a pure-number expression with an
|
|
316
|
+
operator are returned — ``x = 5`` is a definition, not a claim we can check.
|
|
317
|
+
"""
|
|
318
|
+
out: list[tuple[str, float, float]] = []
|
|
319
|
+
for match in _EQUATION.finditer(str(text or "")):
|
|
320
|
+
lhs, rhs = match.group(1).strip(), match.group(2)
|
|
321
|
+
if not re.search(r"[+\-*/×÷^]", lhs) or len(numbers_in(lhs)) < 2:
|
|
322
|
+
continue
|
|
323
|
+
computed = safe_eval_arithmetic(lhs)
|
|
324
|
+
stated = as_number(rhs)
|
|
325
|
+
if computed is None or stated is None:
|
|
326
|
+
continue
|
|
327
|
+
out.append((f"{lhs} = {rhs}", stated, computed))
|
|
328
|
+
return out
|