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,396 @@
|
|
|
1
|
+
"""EvolutionStore — cross-run lesson accumulation with time-decay weighting.
|
|
2
|
+
|
|
3
|
+
Mirrors ``researchclaw/evolution.py`` adapted to the evalrx M1→M5 pipeline.
|
|
4
|
+
|
|
5
|
+
The store is JSONL-backed (append-only) so it accumulates lessons across many
|
|
6
|
+
diagnosis runs. When building prompt overlays for the LLM agents, lessons are
|
|
7
|
+
weighted by a 30-day half-life exponential decay — older insights matter less.
|
|
8
|
+
|
|
9
|
+
Usage::
|
|
10
|
+
|
|
11
|
+
store = EvolutionStore(run_dir / "evolution")
|
|
12
|
+
lessons = extract_lessons(report)
|
|
13
|
+
store.append_many(lessons)
|
|
14
|
+
|
|
15
|
+
# Inject relevant context into M3 DiagnosisAgent prompt
|
|
16
|
+
overlay = store.build_overlay("diagnosis", max_lessons=5)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import math
|
|
24
|
+
import re as _re
|
|
25
|
+
from dataclasses import asdict, dataclass
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import TYPE_CHECKING
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from evalrx.eval_agent.loop_reports import AutoDiagnoseReport
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Keyword tokenisation for relevance scoring
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
_TOKEN_RE = _re.compile(r"[a-z0-9]+")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _tokenise(text: str) -> frozenset[str]:
|
|
43
|
+
"""Lowercase + split on non-alphanumeric characters; return a frozenset of tokens."""
|
|
44
|
+
return frozenset(_TOKEN_RE.findall(text.lower()))
|
|
45
|
+
|
|
46
|
+
# Half-life for time-decay weighting (days)
|
|
47
|
+
HALF_LIFE_DAYS: float = 30.0
|
|
48
|
+
MAX_AGE_DAYS: float = 90.0
|
|
49
|
+
|
|
50
|
+
# Valid category tags matching the M1–M5 pipeline stages
|
|
51
|
+
CATEGORIES = frozenset({"probe", "analysis", "diagnosis", "surgery"})
|
|
52
|
+
SEVERITIES = frozenset({"info", "warning", "error"})
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Data model
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class LessonEntry:
|
|
62
|
+
"""A single lesson extracted from a diagnosis run.
|
|
63
|
+
|
|
64
|
+
Attributes:
|
|
65
|
+
run_id: Identifier of the run that generated this lesson.
|
|
66
|
+
cycle: M1→M5 cycle number within that run.
|
|
67
|
+
category: Pipeline stage: ``"probe" | "analysis" | "diagnosis" | "surgery"``.
|
|
68
|
+
severity: ``"info" | "warning" | "error"``.
|
|
69
|
+
description: Human-readable lesson text (injected into prompts).
|
|
70
|
+
timestamp: ISO 8601 UTC timestamp.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
run_id: str
|
|
74
|
+
cycle: int
|
|
75
|
+
category: str
|
|
76
|
+
severity: str
|
|
77
|
+
description: str
|
|
78
|
+
timestamp: str
|
|
79
|
+
|
|
80
|
+
def to_dict(self) -> dict[str, object]:
|
|
81
|
+
return asdict(self)
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_dict(cls, data: dict[str, object]) -> LessonEntry:
|
|
85
|
+
return cls(
|
|
86
|
+
run_id=str(data.get("run_id", "")),
|
|
87
|
+
cycle=int(data.get("cycle", 0)),
|
|
88
|
+
category=str(data.get("category", "surgery")),
|
|
89
|
+
severity=str(data.get("severity", "info")),
|
|
90
|
+
description=str(data.get("description", "")),
|
|
91
|
+
timestamp=str(data.get("timestamp", _utcnow_iso())),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# Time-decay weighting
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _utcnow_iso() -> str:
|
|
101
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _time_weight(timestamp_iso: str) -> float:
|
|
105
|
+
"""30-day half-life exponential decay; returns 0.0 after 90 days."""
|
|
106
|
+
try:
|
|
107
|
+
ts = datetime.fromisoformat(timestamp_iso)
|
|
108
|
+
if ts.tzinfo is None:
|
|
109
|
+
ts = ts.replace(tzinfo=timezone.utc)
|
|
110
|
+
age_days = (datetime.now(timezone.utc) - ts).total_seconds() / 86400.0
|
|
111
|
+
except (ValueError, TypeError):
|
|
112
|
+
return 0.5 # unknown age — treat as moderate recency
|
|
113
|
+
|
|
114
|
+
if age_days > MAX_AGE_DAYS:
|
|
115
|
+
return 0.0
|
|
116
|
+
return math.exp(-age_days * math.log(2) / HALF_LIFE_DAYS)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# Lesson extraction
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def extract_lessons(report: "AutoDiagnoseReport") -> list[LessonEntry]:
|
|
125
|
+
"""Auto-extract lessons from a completed :class:`AutoDiagnoseReport`.
|
|
126
|
+
|
|
127
|
+
Detects:
|
|
128
|
+
- INCONCLUSIVE hypotheses → surgery/warning
|
|
129
|
+
- Loop unresolved after exhausting max_cycles → diagnosis/warning
|
|
130
|
+
- HIGH or CRITICAL analysis severity with no resolution → analysis/info
|
|
131
|
+
"""
|
|
132
|
+
from evalrx.eval_agent.hypothesis import HypothesisStatus
|
|
133
|
+
|
|
134
|
+
lessons: list[LessonEntry] = []
|
|
135
|
+
run_id = getattr(report, "_run_id", "unknown")
|
|
136
|
+
cycles = getattr(report, "cycles", 0)
|
|
137
|
+
ts = _utcnow_iso()
|
|
138
|
+
|
|
139
|
+
# INCONCLUSIVE hypotheses
|
|
140
|
+
for h in getattr(report, "final_hypotheses", []):
|
|
141
|
+
if getattr(h, "status", None) == HypothesisStatus.INCONCLUSIVE:
|
|
142
|
+
lessons.append(LessonEntry(
|
|
143
|
+
run_id=run_id,
|
|
144
|
+
cycle=cycles,
|
|
145
|
+
category="surgery",
|
|
146
|
+
severity="warning",
|
|
147
|
+
description=(
|
|
148
|
+
f"Hypothesis INCONCLUSIVE: '{h.statement[:120]}' "
|
|
149
|
+
f"(failure_mode={h.predicted_failure_mode}). "
|
|
150
|
+
"Consider a more targeted experiment or more failure cases."
|
|
151
|
+
),
|
|
152
|
+
timestamp=ts,
|
|
153
|
+
))
|
|
154
|
+
|
|
155
|
+
# Loop exhausted without resolution
|
|
156
|
+
if not getattr(report, "resolved", False):
|
|
157
|
+
lessons.append(LessonEntry(
|
|
158
|
+
run_id=run_id,
|
|
159
|
+
cycle=cycles,
|
|
160
|
+
category="diagnosis",
|
|
161
|
+
severity="warning",
|
|
162
|
+
description=(
|
|
163
|
+
f"Loop exhausted {cycles} cycle(s) without resolving the failure. "
|
|
164
|
+
"Hypotheses may need broader scope or additional analyzer coverage."
|
|
165
|
+
),
|
|
166
|
+
timestamp=ts,
|
|
167
|
+
))
|
|
168
|
+
|
|
169
|
+
# High-severity analysis finding without resolution
|
|
170
|
+
analysis = getattr(report, "final_analysis", None)
|
|
171
|
+
if analysis is not None:
|
|
172
|
+
severity = getattr(analysis, "severity", "")
|
|
173
|
+
if severity in ("HIGH", "CRITICAL") and not getattr(report, "resolved", False):
|
|
174
|
+
lessons.append(LessonEntry(
|
|
175
|
+
run_id=run_id,
|
|
176
|
+
cycle=cycles,
|
|
177
|
+
category="analysis",
|
|
178
|
+
severity="info",
|
|
179
|
+
description=(
|
|
180
|
+
f"Analysis severity={severity} but no hypothesis was confirmed. "
|
|
181
|
+
"The analyzer findings may point to a failure mode not yet hypothesised."
|
|
182
|
+
),
|
|
183
|
+
timestamp=ts,
|
|
184
|
+
))
|
|
185
|
+
|
|
186
|
+
return lessons
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
# EvolutionStore
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class EvolutionStore:
|
|
195
|
+
"""JSONL-backed store for diagnosis lessons with 30-day half-life time decay.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
store_dir: Directory where ``lessons.jsonl`` is written.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
def __init__(self, store_dir: Path | str) -> None:
|
|
202
|
+
self._dir = Path(store_dir)
|
|
203
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
self._lessons_path = self._dir / "lessons.jsonl"
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def lessons_path(self) -> Path:
|
|
208
|
+
return self._lessons_path
|
|
209
|
+
|
|
210
|
+
# ------------------------------------------------------------------
|
|
211
|
+
# Write
|
|
212
|
+
# ------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
def append(self, lesson: LessonEntry) -> None:
|
|
215
|
+
"""Append a single lesson to the store."""
|
|
216
|
+
try:
|
|
217
|
+
with self._lessons_path.open("a", encoding="utf-8") as f:
|
|
218
|
+
f.write(json.dumps(lesson.to_dict(), default=str) + "\n")
|
|
219
|
+
except OSError as exc:
|
|
220
|
+
logger.warning("EvolutionStore: failed to append lesson: %s", exc)
|
|
221
|
+
|
|
222
|
+
def append_many(self, lessons: list[LessonEntry]) -> None:
|
|
223
|
+
"""Append multiple lessons in one write."""
|
|
224
|
+
if not lessons:
|
|
225
|
+
return
|
|
226
|
+
try:
|
|
227
|
+
with self._lessons_path.open("a", encoding="utf-8") as f:
|
|
228
|
+
for lesson in lessons:
|
|
229
|
+
f.write(json.dumps(lesson.to_dict(), default=str) + "\n")
|
|
230
|
+
except OSError as exc:
|
|
231
|
+
logger.warning("EvolutionStore: failed to append lessons: %s", exc)
|
|
232
|
+
|
|
233
|
+
# ------------------------------------------------------------------
|
|
234
|
+
# Read
|
|
235
|
+
# ------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
def load_all(self) -> list[LessonEntry]:
|
|
238
|
+
"""Load all lessons from disk."""
|
|
239
|
+
if not self._lessons_path.exists():
|
|
240
|
+
return []
|
|
241
|
+
entries: list[LessonEntry] = []
|
|
242
|
+
try:
|
|
243
|
+
with self._lessons_path.open(encoding="utf-8") as f:
|
|
244
|
+
for line in f:
|
|
245
|
+
line = line.strip()
|
|
246
|
+
if not line:
|
|
247
|
+
continue
|
|
248
|
+
try:
|
|
249
|
+
entries.append(LessonEntry.from_dict(json.loads(line)))
|
|
250
|
+
except (json.JSONDecodeError, KeyError):
|
|
251
|
+
logger.debug("EvolutionStore: skipping malformed line")
|
|
252
|
+
except OSError as exc:
|
|
253
|
+
logger.warning("EvolutionStore: failed to read lessons: %s", exc)
|
|
254
|
+
return entries
|
|
255
|
+
|
|
256
|
+
def query_for_category(
|
|
257
|
+
self,
|
|
258
|
+
category: str,
|
|
259
|
+
*,
|
|
260
|
+
max_lessons: int = 5,
|
|
261
|
+
context_keywords: list[str] | None = None,
|
|
262
|
+
) -> list[LessonEntry]:
|
|
263
|
+
"""Return the most relevant lessons for *category*.
|
|
264
|
+
|
|
265
|
+
Scoring combines three signals so that a highly relevant older lesson
|
|
266
|
+
can rank above a recent but unrelated one:
|
|
267
|
+
|
|
268
|
+
1. **Recency** — 30-day exponential half-life.
|
|
269
|
+
2. **Keyword relevance** — Jaccard-like token overlap between the lesson
|
|
270
|
+
text and the query context supplied via *context_keywords* (e.g. the
|
|
271
|
+
current failure modes, analysis narrative snippets).
|
|
272
|
+
3. **Severity boost** — errors outrank warnings which outrank info.
|
|
273
|
+
|
|
274
|
+
*context_keywords*: additional terms from the current run context.
|
|
275
|
+
The query always includes the category name itself as a token.
|
|
276
|
+
"""
|
|
277
|
+
all_lessons = self.load_all()
|
|
278
|
+
relevant = [lesson for lesson in all_lessons if lesson.category == category]
|
|
279
|
+
if not relevant:
|
|
280
|
+
return []
|
|
281
|
+
|
|
282
|
+
# Build query token set from category + caller-supplied keywords
|
|
283
|
+
query_tokens = _tokenise(category)
|
|
284
|
+
for kw in (context_keywords or []):
|
|
285
|
+
query_tokens |= _tokenise(kw)
|
|
286
|
+
|
|
287
|
+
scored: list[tuple[float, LessonEntry]] = []
|
|
288
|
+
for lesson in relevant:
|
|
289
|
+
weight = _time_weight(lesson.timestamp)
|
|
290
|
+
if weight <= 0.0:
|
|
291
|
+
continue
|
|
292
|
+
|
|
293
|
+
# Keyword relevance: overlap between query and lesson text
|
|
294
|
+
lesson_tokens = _tokenise(lesson.description)
|
|
295
|
+
overlap = len(query_tokens & lesson_tokens)
|
|
296
|
+
if overlap > 0:
|
|
297
|
+
# Cap boost at 3× so recency still matters
|
|
298
|
+
weight *= 1.0 + min(overlap * 0.4, 2.0)
|
|
299
|
+
|
|
300
|
+
# Severity boost
|
|
301
|
+
if lesson.severity == "error":
|
|
302
|
+
weight *= 1.5
|
|
303
|
+
elif lesson.severity == "warning":
|
|
304
|
+
weight *= 1.2
|
|
305
|
+
|
|
306
|
+
scored.append((weight, lesson))
|
|
307
|
+
|
|
308
|
+
scored.sort(key=lambda x: x[0], reverse=True)
|
|
309
|
+
return [entry for _, entry in scored[:max_lessons]]
|
|
310
|
+
|
|
311
|
+
def build_overlay(
|
|
312
|
+
self,
|
|
313
|
+
category: str,
|
|
314
|
+
*,
|
|
315
|
+
max_lessons: int = 5,
|
|
316
|
+
context_keywords: list[str] | None = None,
|
|
317
|
+
caller_run_id: str = "",
|
|
318
|
+
) -> str:
|
|
319
|
+
"""Generate a prompt overlay string for a given M1–M5 *category*.
|
|
320
|
+
|
|
321
|
+
Returns an empty string when no relevant lessons qualify.
|
|
322
|
+
The overlay is formatted for direct injection into LLM prompts.
|
|
323
|
+
|
|
324
|
+
Side-effect: each call is appended to ``injection_log.jsonl`` so
|
|
325
|
+
there is an immutable audit trail of which lessons were injected
|
|
326
|
+
into which runs. This makes it possible to bisect a regression
|
|
327
|
+
back to the specific lesson that changed agent behaviour.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
context_keywords: Extra terms from the current run used to boost
|
|
331
|
+
relevance scoring (failure mode tags, narrative snippets, etc.).
|
|
332
|
+
caller_run_id: Identifier of the run requesting the overlay —
|
|
333
|
+
used to link the injection record back to the loop run.
|
|
334
|
+
"""
|
|
335
|
+
lessons = self.query_for_category(
|
|
336
|
+
category, max_lessons=max_lessons, context_keywords=context_keywords
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
self._record_injection(category, lessons, caller_run_id)
|
|
340
|
+
|
|
341
|
+
if not lessons:
|
|
342
|
+
return ""
|
|
343
|
+
|
|
344
|
+
lines = ["## Lessons from Prior Diagnosis Runs"]
|
|
345
|
+
for i, lesson in enumerate(lessons, 1):
|
|
346
|
+
icon = {"error": "[ERROR]", "warning": "[WARN]", "info": "[INFO]"}.get(
|
|
347
|
+
lesson.severity, "[INFO]"
|
|
348
|
+
)
|
|
349
|
+
lines.append(f"{i}. {icon} {lesson.description}")
|
|
350
|
+
return "\n".join(lines)
|
|
351
|
+
|
|
352
|
+
def _record_injection(
|
|
353
|
+
self,
|
|
354
|
+
category: str,
|
|
355
|
+
lessons: list[LessonEntry],
|
|
356
|
+
caller_run_id: str,
|
|
357
|
+
) -> None:
|
|
358
|
+
"""Append one record to ``injection_log.jsonl`` (non-blocking).
|
|
359
|
+
|
|
360
|
+
Each record captures which lessons were injected, when, into which
|
|
361
|
+
run, and for which category. The log is append-only so it acts as
|
|
362
|
+
a provenance chain — a given mutation in agent behaviour can be
|
|
363
|
+
traced back to the exact lesson that was active at the time.
|
|
364
|
+
"""
|
|
365
|
+
log_path = self._dir / "injection_log.jsonl"
|
|
366
|
+
record = {
|
|
367
|
+
"ts": _utcnow_iso(),
|
|
368
|
+
"caller_run_id": caller_run_id,
|
|
369
|
+
"category": category,
|
|
370
|
+
"n_lessons": len(lessons),
|
|
371
|
+
"lessons": [
|
|
372
|
+
{
|
|
373
|
+
"run_id": lesson.run_id,
|
|
374
|
+
"cycle": lesson.cycle,
|
|
375
|
+
"severity": lesson.severity,
|
|
376
|
+
"description": lesson.description[:120],
|
|
377
|
+
"timestamp": lesson.timestamp,
|
|
378
|
+
}
|
|
379
|
+
for lesson in lessons
|
|
380
|
+
],
|
|
381
|
+
}
|
|
382
|
+
try:
|
|
383
|
+
with log_path.open("a", encoding="utf-8") as f:
|
|
384
|
+
f.write(json.dumps(record, default=str) + "\n")
|
|
385
|
+
except OSError as exc:
|
|
386
|
+
logger.debug("EvolutionStore: could not write injection log: %s", exc)
|
|
387
|
+
|
|
388
|
+
def count(self) -> int:
|
|
389
|
+
"""Return total number of lessons stored."""
|
|
390
|
+
if not self._lessons_path.exists():
|
|
391
|
+
return 0
|
|
392
|
+
try:
|
|
393
|
+
with self._lessons_path.open(encoding="utf-8") as f:
|
|
394
|
+
return sum(1 for line in f if line.strip())
|
|
395
|
+
except OSError:
|
|
396
|
+
return 0
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Git-native experiment version management for the eval_agent loop.
|
|
2
|
+
|
|
3
|
+
Every successful diagnosis run is committed on a branch ``eval/{run_id}``;
|
|
4
|
+
failed or unresolved runs are reset with ``git reset --hard``. This enables
|
|
5
|
+
``git log`` as a diagnosis journal and easy rollback to a prior state.
|
|
6
|
+
|
|
7
|
+
Ported from ``researchclaw/experiment/git_manager.py`` with adaptations:
|
|
8
|
+
- Branch naming: ``eval/{run_id}`` (was ``experiment/{tag}``)
|
|
9
|
+
- Commit format: ``eval({run_id}) cycle {N}: {desc}\\n\\nHypotheses: {json}``
|
|
10
|
+
- History grep: ``eval(`` (was ``experiment(``)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import re
|
|
18
|
+
import subprocess
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ExperimentGitManager:
|
|
25
|
+
"""Git-native versioning for eval_agent diagnosis runs.
|
|
26
|
+
|
|
27
|
+
Every successful run is a commit on ``eval/{run_id}``. Failed / unresolved
|
|
28
|
+
runs are discarded with ``git reset --hard HEAD``. Git log becomes the
|
|
29
|
+
experiment journal; ``git checkout eval/{run_id}`` replays any prior run.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, repo_dir: Path) -> None:
|
|
33
|
+
self.repo_dir: Path = repo_dir
|
|
34
|
+
self._active_branch: str | None = None
|
|
35
|
+
self._original_branch: str | None = self._detect_current_branch()
|
|
36
|
+
|
|
37
|
+
# ------------------------------------------------------------------
|
|
38
|
+
# Public API
|
|
39
|
+
# ------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
def is_git_repo(self) -> bool:
|
|
42
|
+
"""Return True if *repo_dir* is inside a git repository."""
|
|
43
|
+
result = self._run_git(["rev-parse", "--is-inside-work-tree"])
|
|
44
|
+
return result is not None and result.returncode == 0
|
|
45
|
+
|
|
46
|
+
def get_current_branch(self) -> str:
|
|
47
|
+
"""Return the active branch name, or '' if detection fails."""
|
|
48
|
+
return self._detect_current_branch() or ""
|
|
49
|
+
|
|
50
|
+
def create_experiment_branch(self, run_id: str) -> str:
|
|
51
|
+
"""Create and check out branch ``eval/{run_id}``.
|
|
52
|
+
|
|
53
|
+
Returns the branch name on success, or '' on failure.
|
|
54
|
+
"""
|
|
55
|
+
branch = f"eval/{run_id}"
|
|
56
|
+
result = self._run_git(["checkout", "-b", branch])
|
|
57
|
+
if result is None or result.returncode != 0:
|
|
58
|
+
self._log_git_failure("create_experiment_branch", result)
|
|
59
|
+
return ""
|
|
60
|
+
self._active_branch = branch
|
|
61
|
+
return branch
|
|
62
|
+
|
|
63
|
+
def commit_experiment(
|
|
64
|
+
self,
|
|
65
|
+
run_id: str,
|
|
66
|
+
cycle: int,
|
|
67
|
+
hypothesis_statuses: dict[str, str],
|
|
68
|
+
description: str = "",
|
|
69
|
+
) -> str:
|
|
70
|
+
"""Stage all changes and commit.
|
|
71
|
+
|
|
72
|
+
Returns the commit hash on success, or '' on failure.
|
|
73
|
+
"""
|
|
74
|
+
add_result = self._run_git(["add", "-A"])
|
|
75
|
+
if add_result is None or add_result.returncode != 0:
|
|
76
|
+
self._log_git_failure("git add", add_result)
|
|
77
|
+
return ""
|
|
78
|
+
|
|
79
|
+
message = self._format_commit_message(
|
|
80
|
+
run_id=run_id,
|
|
81
|
+
cycle=cycle,
|
|
82
|
+
hypothesis_statuses=hypothesis_statuses,
|
|
83
|
+
description=description,
|
|
84
|
+
)
|
|
85
|
+
commit_result = self._run_git(["commit", "-m", message])
|
|
86
|
+
if commit_result is None or commit_result.returncode != 0:
|
|
87
|
+
self._log_git_failure("git commit", commit_result)
|
|
88
|
+
return ""
|
|
89
|
+
|
|
90
|
+
hash_result = self._run_git(["rev-parse", "HEAD"])
|
|
91
|
+
if hash_result is None or hash_result.returncode != 0:
|
|
92
|
+
self._log_git_failure("git rev-parse HEAD", hash_result)
|
|
93
|
+
return ""
|
|
94
|
+
return self._clean_output(hash_result.stdout)
|
|
95
|
+
|
|
96
|
+
def discard_experiment(self, run_id: str, reason: str) -> bool:
|
|
97
|
+
"""Reset working tree to HEAD (discard uncommitted changes).
|
|
98
|
+
|
|
99
|
+
Returns True on success.
|
|
100
|
+
"""
|
|
101
|
+
logger.info("Discarding eval run %s: %s", run_id, reason)
|
|
102
|
+
result = self._run_git(["reset", "--hard", "HEAD"])
|
|
103
|
+
if result is None or result.returncode != 0:
|
|
104
|
+
self._log_git_failure("discard_experiment", result)
|
|
105
|
+
return False
|
|
106
|
+
return True
|
|
107
|
+
|
|
108
|
+
def get_experiment_history(self) -> list[dict[str, str]]:
|
|
109
|
+
"""Return a list of prior eval commits parsed from git log."""
|
|
110
|
+
result = self._run_git(
|
|
111
|
+
["log", "--oneline", "--fixed-strings", "--grep", "eval("]
|
|
112
|
+
)
|
|
113
|
+
if result is None or result.returncode != 0:
|
|
114
|
+
self._log_git_failure("git log", result)
|
|
115
|
+
return []
|
|
116
|
+
|
|
117
|
+
history: list[dict[str, str]] = []
|
|
118
|
+
for line in result.stdout.splitlines():
|
|
119
|
+
parsed = self._parse_experiment_log_line(line)
|
|
120
|
+
if parsed is not None:
|
|
121
|
+
history.append(parsed)
|
|
122
|
+
return history
|
|
123
|
+
|
|
124
|
+
def return_to_original_branch(self) -> bool:
|
|
125
|
+
"""Switch back to the branch that was active at init time."""
|
|
126
|
+
if not self._original_branch:
|
|
127
|
+
return False
|
|
128
|
+
result = self._run_git(["checkout", self._original_branch])
|
|
129
|
+
if result is None or result.returncode != 0:
|
|
130
|
+
self._log_git_failure("return_to_original_branch", result)
|
|
131
|
+
return False
|
|
132
|
+
self._active_branch = self._original_branch
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
def get_experiment_diff(self) -> str:
|
|
136
|
+
"""Return ``git diff --stat`` of uncommitted changes (for logging)."""
|
|
137
|
+
result = self._run_git(["diff", "--stat"])
|
|
138
|
+
if result is None or result.returncode != 0:
|
|
139
|
+
return ""
|
|
140
|
+
return result.stdout.strip()
|
|
141
|
+
|
|
142
|
+
def clean_untracked(self) -> bool:
|
|
143
|
+
"""Remove untracked files and directories (``git clean -fd``)."""
|
|
144
|
+
result = self._run_git(["clean", "-fd"])
|
|
145
|
+
return result is not None and result.returncode == 0
|
|
146
|
+
|
|
147
|
+
# ------------------------------------------------------------------
|
|
148
|
+
# Internal helpers
|
|
149
|
+
# ------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
def _run_git(self, args: list[str]) -> subprocess.CompletedProcess[str] | None:
|
|
152
|
+
try:
|
|
153
|
+
logger.debug("git %s", " ".join(args))
|
|
154
|
+
return subprocess.run(
|
|
155
|
+
["git", *args],
|
|
156
|
+
cwd=self.repo_dir,
|
|
157
|
+
capture_output=True,
|
|
158
|
+
text=True,
|
|
159
|
+
encoding="utf-8",
|
|
160
|
+
errors="replace",
|
|
161
|
+
check=False,
|
|
162
|
+
)
|
|
163
|
+
except Exception as exc: # noqa: BLE001
|
|
164
|
+
logger.warning("Git operation failed (%s): %s", " ".join(args), exc)
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def _format_commit_message(
|
|
169
|
+
*,
|
|
170
|
+
run_id: str,
|
|
171
|
+
cycle: int,
|
|
172
|
+
hypothesis_statuses: dict[str, str],
|
|
173
|
+
description: str,
|
|
174
|
+
) -> str:
|
|
175
|
+
statuses_json = json.dumps(hypothesis_statuses, sort_keys=True)
|
|
176
|
+
desc = description or "diagnosis complete"
|
|
177
|
+
return (
|
|
178
|
+
f"eval({run_id}) cycle {cycle}: {desc}\n\n"
|
|
179
|
+
f"Hypotheses: {statuses_json}"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
@staticmethod
|
|
183
|
+
def _clean_output(output: str) -> str:
|
|
184
|
+
return output.strip()
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
def _parse_experiment_log_line(line: str) -> dict[str, str] | None:
|
|
188
|
+
pattern = re.compile(r"^([0-9a-fA-F]+)\s+eval\(([^)]+)\)\s+cycle\s+(\d+):\s*(.*)$")
|
|
189
|
+
match = pattern.match(line.strip())
|
|
190
|
+
if match is None:
|
|
191
|
+
return None
|
|
192
|
+
commit_hash, run_id, cycle, message = match.groups()
|
|
193
|
+
return {"hash": commit_hash, "run_id": run_id, "cycle": cycle, "message": message}
|
|
194
|
+
|
|
195
|
+
@staticmethod
|
|
196
|
+
def _log_git_failure(
|
|
197
|
+
operation: str, result: subprocess.CompletedProcess[str] | None
|
|
198
|
+
) -> None:
|
|
199
|
+
if result is None:
|
|
200
|
+
logger.warning("Git operation failed for %s", operation)
|
|
201
|
+
return
|
|
202
|
+
stderr = result.stderr.strip()
|
|
203
|
+
if stderr:
|
|
204
|
+
logger.warning("Git operation failed for %s: %s", operation, stderr)
|
|
205
|
+
else:
|
|
206
|
+
logger.warning(
|
|
207
|
+
"Git operation failed for %s with code %s", operation, result.returncode
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def _detect_current_branch(self) -> str | None:
|
|
211
|
+
result = self._run_git(["rev-parse", "--abbrev-ref", "HEAD"])
|
|
212
|
+
if result is None or result.returncode != 0:
|
|
213
|
+
return None
|
|
214
|
+
name = result.stdout.strip()
|
|
215
|
+
return name if name else None
|