| |
| """Retrospective before/after analysis of post-analysis CSV re-reads. |
| |
| Answers the question `1ec068b` shipped without answering: **did returning |
| `top_table` from `dataset_compare_activity_by_group` actually stop the agent |
| re-opening `comparison_path` before writing its solution?** |
| |
| The intent was a retrospective control arm: historical run records carry their |
| own `messages`, so `n_post_analysis_reads` could be recomputed with no re-running |
| and no reverting (both Spaces run the new code, so there is no old-code |
| deployment left to A/B against). |
| |
| **That control arm does not exist in the data.** As of 2026-08-06 all 20 run |
| traces in `anne-voigt/decoupleRpy_results` persist `"messages": []`, because the |
| Gradio path drives `graph.stream()` directly and never fills |
| `WorkflowEngine.message_history` — the field `get_trace()` reads. Every |
| production trace was therefore written empty. `src/agent.py` now falls back to |
| the final graph state, so records written from here on carry the conversation; |
| the pre-fix baseline is unrecoverable and has to be rebuilt forward. This script |
| reports that explicitly (`no_messages`) rather than scoring an empty record as |
| zero re-reads. |
| |
| The counting logic is imported from ``src/core/run_metrics.py`` — the SAME code |
| the live instrumentation uses. Do not re-implement it here: a divergence between |
| the two would silently invalidate the comparison. |
| |
| Arm assignment is by the record's own ``execution_time`` versus ``--cutoff`` |
| (the `1ec068b` prod deploy). It deliberately does NOT use which sink the run |
| landed in: dev runs currently write to the PROD sink despite |
| ``LOG_SINK_HF_DATASET`` pointing at ``…_dev`` (open TODO). Splitting on time |
| sidesteps that bug rather than depending on it — harmless today because both |
| Spaces run identical code, but it is why the sink is not the discriminator. |
| |
| NOTE: this file must NOT be named ``test_*.py`` — pytest collects on filename |
| alone and a script that exits at import kills the whole run. |
| |
| Usage:: |
| |
| # local run_logs |
| python scripts/analyze_post_analysis_reads.py --source local --dir ./run_logs |
| |
| # the prod HF dataset (needs decouplerpy_results_token) |
| python scripts/analyze_post_analysis_reads.py --source hf |
| |
| # dump the per-run table too |
| python scripts/analyze_post_analysis_reads.py --source hf --per-run |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import os |
| import sys |
| from datetime import datetime |
| from pathlib import Path |
|
|
| REPO_ROOT = Path(__file__).resolve().parents[1] |
| sys.path.insert(0, str(REPO_ROOT / "src")) |
|
|
| from core.run_metrics import compute_run_metrics |
|
|
| |
| DEFAULT_CUTOFF = "2026-08-05T00:00:00" |
|
|
| |
| |
| |
| FIXED_TOOL = "dataset_compare_activity_by_group" |
|
|
|
|
| |
| |
| |
| def load_local(directory: str) -> list[tuple[str, dict]]: |
| out = [] |
| for path in sorted(Path(directory).glob("**/*.json")): |
| try: |
| with open(path, encoding="utf-8") as f: |
| record = json.load(f) |
| if is_run_record(record): |
| out.append((path.stem, record)) |
| except Exception as exc: |
| print(f" ! skipping {path}: {exc}", file=sys.stderr) |
| return out |
|
|
|
|
| def is_run_record(record: dict) -> bool: |
| """Filter out the upload/audit records that share the same sink.""" |
| return isinstance(record, dict) and "upload_id" not in record and "messages" in record |
|
|
|
|
| def load_hf(repo_id: str) -> list[tuple[str, dict]]: |
| from huggingface_hub import HfApi, hf_hub_download |
|
|
| token = os.environ.get("decouplerpy_results_token") |
| api = HfApi(token=token) |
| files = [ |
| f |
| for f in api.list_repo_files(repo_id=repo_id, repo_type="dataset") |
| if f.endswith("/trace.json") |
| ] |
| print(f"Found {len(files)} trace records in {repo_id}") |
|
|
| out = [] |
| for remote in files: |
| try: |
| local = hf_hub_download( |
| repo_id=repo_id, filename=remote, repo_type="dataset", token=token |
| ) |
| with open(local, encoding="utf-8") as f: |
| record = json.load(f) |
| if is_run_record(record): |
| out.append((remote.split("/")[-2], record)) |
| except Exception as exc: |
| print(f" ! skipping {remote}: {exc}", file=sys.stderr) |
| return out |
|
|
|
|
| |
| |
| |
| def _parse_time(record: dict, run_id: str) -> datetime | None: |
| raw = record.get("execution_time") |
| for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"): |
| try: |
| return datetime.strptime(str(raw), fmt) |
| except (TypeError, ValueError): |
| continue |
| |
| try: |
| return datetime.strptime(run_id[:15], "%Y%m%d_%H%M%S") |
| except ValueError: |
| return None |
|
|
|
|
| def summarize(records: list[tuple[str, dict]], cutoff: datetime) -> list[dict]: |
| rows = [] |
| for run_id, record in records: |
| metrics = compute_run_metrics(record.get("messages") or []) |
| when = _parse_time(record, run_id) |
| rows.append( |
| { |
| "run_id": run_id, |
| "time": when, |
| "arm": None if when is None else ("after" if when >= cutoff else "before"), |
| **metrics, |
| } |
| ) |
| return rows |
|
|
|
|
| def fisher(before_hits, before_n, after_hits, after_n): |
| """Two-sided Fisher exact p for (≥1 re-read) before vs after.""" |
| try: |
| from scipy.stats import fisher_exact |
| except ImportError: |
| return None |
| table = [ |
| [before_hits, before_n - before_hits], |
| [after_hits, after_n - after_hits], |
| ] |
| return float(fisher_exact(table)[1]) |
|
|
|
|
| def report(rows: list[dict], per_run: bool) -> None: |
| measurable = [r for r in rows if r["n_post_analysis_reads"] is not None] |
| unmeasurable = [r for r in rows if r["n_post_analysis_reads"] is None] |
|
|
| print(f"\nRuns loaded: {len(rows)}") |
| print(f" measurable: {len(measurable)} (a terminal analysis tool wrote a results CSV)") |
| print(f" not measurable: {len(unmeasurable)} (excluded — NOT counted as zero re-reads)") |
| reasons: dict[str, int] = {} |
| for r in unmeasurable: |
| key = r.get("unmeasurable_reason") or "unknown" |
| reasons[key] = reasons.get(key, 0) + 1 |
| for reason, n in sorted(reasons.items(), key=lambda kv: -kv[1]): |
| note = "" |
| if reason == "no_messages": |
| note = " ← the record persisted an empty conversation; nothing to count" |
| print(f" {reason:<28} {n}{note}") |
| no_arm = [r for r in measurable if r["arm"] is None] |
| if no_arm: |
| print(f" undated: {len(no_arm)} (excluded from the arms)") |
|
|
| tools = sorted({r["terminal_tool"] or "(unknown)" for r in measurable}) |
| print("\nBy terminal tool — fraction of runs with ≥1 post-analysis re-read") |
| print(f"{'terminal tool':<45} {'before':>14} {'after':>14} {'fisher p':>10}") |
| print("-" * 87) |
|
|
| for tool in tools: |
| arm_counts = {} |
| for arm in ("before", "after"): |
| sel = [ |
| r |
| for r in measurable |
| if (r["terminal_tool"] or "(unknown)") == tool and r["arm"] == arm |
| ] |
| hits = sum(1 for r in sel if r["n_post_analysis_reads"] >= 1) |
| arm_counts[arm] = (hits, len(sel)) |
| (bh, bn), (ah, an) = arm_counts["before"], arm_counts["after"] |
|
|
| def fmt(h, n): |
| return f"{h}/{n}" + (f" ({h / n:.0%})" if n else " (n=0)") |
|
|
| p = fisher(bh, bn, ah, an) if bn and an else None |
| p_str = f"{p:.4f}" if p is not None else "—" |
| mark = " ← the fixed tool" if tool == FIXED_TOOL else "" |
| print(f"{tool:<45} {fmt(bh, bn):>14} {fmt(ah, an):>14} {p_str:>10}{mark}") |
|
|
| print( |
| "\nOnly " + FIXED_TOOL + " was changed by 1ec068b; a persisting tail on the other\n" |
| "tools is expected, not a refutation. Do not average across rows." |
| ) |
|
|
| if per_run: |
| print("\nPer-run detail") |
| for r in sorted(measurable, key=lambda r: r["time"] or datetime.min): |
| print( |
| f" {r['run_id']:<24} {str(r['arm']):<7} " |
| f"reads={r['n_post_analysis_reads']} " |
| f"after_steps={r['n_steps_after_analysis']} " |
| f"tool={r['terminal_tool']}" |
| ) |
|
|
|
|
| def main() -> int: |
| ap = argparse.ArgumentParser(description=__doc__) |
| ap.add_argument("--source", choices=("local", "hf"), default="local") |
| ap.add_argument("--dir", default="./run_logs", help="local source directory") |
| ap.add_argument( |
| "--repo", |
| default=os.environ.get("LOG_SINK_HF_DATASET", "anne-voigt/decoupleRpy_results"), |
| help="HF dataset repo id (hf source)", |
| ) |
| ap.add_argument("--cutoff", default=DEFAULT_CUTOFF, help="1ec068b deploy instant (ISO)") |
| ap.add_argument("--per-run", action="store_true") |
| args = ap.parse_args() |
|
|
| cutoff = datetime.fromisoformat(args.cutoff) |
| records = load_local(args.dir) if args.source == "local" else load_hf(args.repo) |
| if not records: |
| print("No run records found — nothing to report.", file=sys.stderr) |
| return 1 |
|
|
| report(summarize(records, cutoff), args.per_run) |
| return 0 |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|