4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
346 lines
14 KiB
Python
346 lines
14 KiB
Python
"""Analyze a CloudOpsBench validation run (read-only over cases/*.json).
|
||
|
||
Compares bench arms per shape stratum and reports:
|
||
|
||
- per-stratum a1 / object_a1 / false-healthy rate per arm
|
||
- L0 vs L1 panel: ``investigation_a1`` (opensre prose) vs ``a1`` (predictor
|
||
rank-1) and translation-loss rate
|
||
- paired ``opensre+llm − control`` contrasts on **a1** and **investigation_a1**
|
||
(scenario-clustered bootstrap CI) — use investigation_a1 to answer whether
|
||
opensre's investigation improved, not just the LLM formalizer
|
||
- translation-loss proxy: among failures, how often opensre's report NAMED
|
||
the correct fault_object but the predictor's top-3 dropped it
|
||
|
||
Usage:
|
||
uv run python -m tests.benchmarks.cloudopsbench.analyze_validation \
|
||
.bench-results/cloudopsbench_fixa_validation_openai/<run-id>
|
||
|
||
Pass a run directory (the one containing ``cases/``). Exploratory only — this
|
||
is a dev-pilot analyzer, not a publication report generator.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import glob
|
||
import json
|
||
import random
|
||
import sys
|
||
from collections.abc import Callable
|
||
from pathlib import Path
|
||
|
||
from tests.benchmarks.cloudopsbench.scoring import (
|
||
infer_final_answer_from_opensre_text,
|
||
)
|
||
|
||
_DEFAULT_ARMS = ("opensre+llm", "llm_alone", "llm_alone_pure")
|
||
|
||
# Minimum service-name length for the seen-shape translation-loss substring
|
||
# proxy. All seen-shape ground-truth objects are ``app/<service>`` with
|
||
# specific multi-token names (e.g. ``ts-voucher-service``), so a substring
|
||
# match in the report is a reliable "the investigation named this service"
|
||
# signal. We do NOT gate on a hard-coded service list — the corpus has more
|
||
# services (esp. trainticket) than any short allowlist, and an incomplete list
|
||
# silently undercounts the leak.
|
||
_MIN_SERVICE_NAME_LEN = 4
|
||
|
||
|
||
def _norm(s: object) -> str:
|
||
return str(s or "").strip().lower()
|
||
|
||
|
||
def _load(run_dir: Path) -> list[dict]:
|
||
rows: list[dict] = []
|
||
for fp in sorted(glob.glob(str(run_dir / "cases" / "*.json"))):
|
||
try:
|
||
rows.append(json.loads(Path(fp).read_text(encoding="utf-8")))
|
||
except (json.JSONDecodeError, OSError):
|
||
continue
|
||
return rows
|
||
|
||
|
||
def _gt(case: dict) -> tuple[str, str, str]:
|
||
g = case.get("metadata", {}).get("ground_truth", {})
|
||
return _norm(g.get("fault_object")), _norm(g.get("root_cause")), _norm(g.get("fault_taxonomy"))
|
||
|
||
|
||
def _top(run: dict) -> list[dict]:
|
||
return (run.get("final_diagnosis") or {}).get("top_3_predictions") or []
|
||
|
||
|
||
def _is_a1(pred: dict, gt: tuple[str, str, str]) -> bool:
|
||
go, gr, gtax = gt
|
||
return (
|
||
_norm(pred.get("fault_object")) == go
|
||
and _norm(pred.get("root_cause")) == gr
|
||
and _norm(pred.get("fault_taxonomy")) == gtax
|
||
)
|
||
|
||
|
||
def _bootstrap_ci(deltas: list[float], iters: int = 2000) -> tuple[float, float, float]:
|
||
if not deltas:
|
||
return float("nan"), float("nan"), float("nan")
|
||
pt = sum(deltas) / len(deltas)
|
||
random.seed(42)
|
||
boots = []
|
||
for _ in range(iters):
|
||
samp = [deltas[random.randrange(len(deltas))] for _ in deltas]
|
||
boots.append(sum(samp) / len(samp))
|
||
boots.sort()
|
||
return pt, boots[int(0.025 * len(boots))], boots[int(0.975 * len(boots))]
|
||
|
||
|
||
def _arms_in_run(rows: list[dict]) -> tuple[str, ...]:
|
||
seen = sorted({str(r["run"]["mode"]) for r in rows if r.get("run")})
|
||
# Prefer canonical order; append any extra modes present in the run.
|
||
ordered = [a for a in _DEFAULT_ARMS if a in seen]
|
||
extras = [a for a in seen if a not in ordered]
|
||
return tuple(ordered + extras)
|
||
|
||
|
||
def _metric(row: dict, name: str) -> float | None:
|
||
"""Read a scored metric from the cell artifact when the runner recorded it."""
|
||
score = row.get("score") or {}
|
||
metrics = score.get("metrics") if isinstance(score, dict) else None
|
||
if isinstance(metrics, dict) and name in metrics:
|
||
return float(metrics[name])
|
||
return None
|
||
|
||
|
||
def analyze(run_dir: Path) -> int:
|
||
rows = _load(run_dir)
|
||
if not rows:
|
||
print(f"No case files found under {run_dir}/cases/")
|
||
return 1
|
||
|
||
arms = _arms_in_run(rows)
|
||
control_arm = "llm_alone_pure" if "llm_alone_pure" in arms else "llm_alone"
|
||
|
||
print(f"Validation analysis: {run_dir.name} ({len(rows)} cells)\n")
|
||
|
||
# Per-stratum per-arm summary.
|
||
for seen in (True, False):
|
||
label = "SEEN-shape" if seen else "UNSEEN-shape"
|
||
print(f"=== {label} ===")
|
||
print(f"{'arm':<14}{'n':>5}{'a1':>8}{'object_a1':>11}{'healthy%':>10}")
|
||
for arm in arms:
|
||
cells = [
|
||
r for r in rows if r["run"]["mode"] == arm and r["case"].get("seen_shape") is seen
|
||
]
|
||
if not cells:
|
||
continue
|
||
a1 = obj = healthy = 0
|
||
for r in cells:
|
||
gt = _gt(r["case"])
|
||
preds = _top(r["run"])
|
||
p1 = preds[0] if preds else {}
|
||
if preds and _is_a1(p1, gt):
|
||
a1 += 1
|
||
if preds and _norm(p1.get("fault_object")) == gt[0]:
|
||
obj += 1
|
||
if _norm((r["run"].get("final_diagnosis") or {}).get("stage")) == "healthy":
|
||
healthy += 1
|
||
n = len(cells)
|
||
print(f"{arm:<14}{n:>5}{a1 / n:>8.3f}{obj / n:>11.3f}{100 * healthy / n:>9.1f}%")
|
||
print()
|
||
|
||
# L0 (investigation) vs L1 (predictor) side-by-side per arm.
|
||
#
|
||
# This is the "are we benchmarking opensre or the LLM wrapping its text?"
|
||
# panel. ``investigation_a1`` rebuilds a paper triple from opensre's
|
||
# prose using the same keyword parser the legacy bridge uses (with
|
||
# ``include_predictor_output=False`` so the predictor's structured JSON
|
||
# doesn't feed back through). The gap a1 − investigation_a1 is the
|
||
# predictor's contribution; ``translation_loss`` is the wrong-direction
|
||
# half (opensre right, predictor wrong).
|
||
#
|
||
# Read the panel as:
|
||
# - inv_a1 column = opensre's own ability, conservative lower bound
|
||
# - a1 column = full pipeline (investigate → predictor → rank-1)
|
||
# - tl% column = how often the predictor LOST what opensre named
|
||
print("=== L0 (investigation) vs L1 (predictor) ===")
|
||
print(" inv_a1 = opensre prose alone (lower bound on investigation quality)")
|
||
print(" a1 = top_3_predictions[0] (paper-compatible headline)")
|
||
print(" tl% = translation loss: inv_a1 right but a1 wrong")
|
||
print(f"{'arm':<14}{'n':>5}{'inv_a1':>9}{'a1':>8}{'gap':>8}{'tl%':>7}")
|
||
for arm in arms:
|
||
cells = [r for r in rows if r["run"]["mode"] == arm]
|
||
if not cells:
|
||
continue
|
||
n = len(cells)
|
||
inv_a1 = a1 = tl = 0
|
||
for r in cells:
|
||
gt = _gt(r["case"])
|
||
inv_hit = _investigation_a1_hit(r, gt)
|
||
a1_hit = _cell_a1(r)
|
||
inv_a1 += inv_hit
|
||
a1 += a1_hit
|
||
if inv_hit and not a1_hit:
|
||
tl += 1
|
||
gap = (a1 - inv_a1) / n
|
||
print(f"{arm:<14}{n:>5}{inv_a1 / n:>9.3f}{a1 / n:>8.3f}{gap:>+8.3f}{100 * tl / n:>6.1f}%")
|
||
print()
|
||
|
||
# Paired contrasts per stratum — L1 (a1) and L0 (investigation_a1).
|
||
if "opensre+llm" in arms and control_arm in arms:
|
||
for metric_label, hit_fn in (
|
||
("a1 (predictor rank-1)", _cell_a1),
|
||
(
|
||
"investigation_a1 (opensre prose)",
|
||
lambda r: _investigation_a1_hit(r, _gt(r["case"])),
|
||
),
|
||
):
|
||
print(f"=== paired {metric_label}: (opensre+llm) − ({control_arm}) ===")
|
||
for seen in (True, False, None):
|
||
label = {True: "seen", False: "unseen", None: "all"}[seen]
|
||
|
||
def scen_hit(
|
||
arm: str,
|
||
seen: bool | None = seen,
|
||
hit_fn: Callable[[dict], int] = hit_fn,
|
||
) -> dict[str, float]:
|
||
by: dict[str, list[int]] = {}
|
||
for r in rows:
|
||
if r["run"]["mode"] != arm:
|
||
continue
|
||
if seen is not None and r["case"].get("seen_shape") is not seen:
|
||
continue
|
||
hit = hit_fn(r)
|
||
by.setdefault(r["case"]["case_id"], []).append(hit)
|
||
return {k: sum(v) / len(v) for k, v in by.items()}
|
||
|
||
a = scen_hit("opensre+llm")
|
||
b = scen_hit(control_arm)
|
||
shared = sorted(set(a) & set(b))
|
||
deltas = [a[k] - b[k] for k in shared]
|
||
pt, lo, hi = _bootstrap_ci(deltas)
|
||
verdict = (
|
||
"ns (incl 0)"
|
||
if (lo <= 0 <= hi)
|
||
else ("opensre+ SIG" if pt > 0 else "control+ SIG")
|
||
)
|
||
print(
|
||
f" {label:<7} d={pt:+.4f} 95%CI[{lo:+.4f},{hi:+.4f}] "
|
||
f"n_scen={len(shared):>3} {verdict}"
|
||
)
|
||
print()
|
||
|
||
# Translation-loss proxy (seen-shape, the Fix-A target).
|
||
print("=== translation-loss proxy (seen-shape failures) ===")
|
||
print(" report NAMED correct fault_object but predictor dropped it from top-3")
|
||
for arm in arms:
|
||
fails = dropped = 0
|
||
for r in rows:
|
||
if r["run"]["mode"] != arm or not r["case"].get("seen_shape"):
|
||
continue
|
||
gt = _gt(r["case"])
|
||
preds = _top(r["run"])
|
||
p1 = preds[0] if preds else {}
|
||
if preds and _is_a1(p1, gt):
|
||
continue
|
||
fails += 1
|
||
gt_name = gt[0].split("/")[-1]
|
||
report = _norm((r["run"].get("final_diagnosis") or {}).get("report"))
|
||
named = len(gt_name) >= _MIN_SERVICE_NAME_LEN and gt_name in report
|
||
in_top3 = any(_norm(p.get("fault_object")) == gt[0] for p in preds)
|
||
if named and not in_top3:
|
||
dropped += 1
|
||
if fails:
|
||
print(f" {arm:<14} {dropped}/{fails} = {100 * dropped / fails:.1f}% of failures")
|
||
print()
|
||
|
||
# B2 false-healthy guard activations (Path B, 2026-06-07).
|
||
# The guard rewrites a false-healthy investigation to root_cause_category=unknown
|
||
# with a fixed signature string. Detect fired cells by that signature so the
|
||
# analyzer can split fired vs non-fired a1 per arm — the headline B2 impact.
|
||
print("=== B2 false-healthy guard activations ===")
|
||
print(" cells where the guard downgraded a false-healthy conclusion")
|
||
any_fired = False
|
||
for arm in arms:
|
||
cells = [r for r in rows if r["run"]["mode"] == arm]
|
||
if not cells:
|
||
continue
|
||
fired = [r for r in cells if _b2_fired(r)]
|
||
non_fired = [r for r in cells if not _b2_fired(r)]
|
||
if not fired:
|
||
print(f" {arm:<14} 0 / {len(cells)} cells fired")
|
||
continue
|
||
any_fired = True
|
||
fired_a1 = sum(1 for r in fired if _cell_a1(r))
|
||
non_fired_a1 = sum(1 for r in non_fired if _cell_a1(r))
|
||
fire_rate = 100 * len(fired) / len(cells)
|
||
non_fired_a1_rate = non_fired_a1 / len(non_fired) if non_fired else 0.0
|
||
print(
|
||
f" {arm:<14} {len(fired):3d} / {len(cells):3d} = {fire_rate:5.1f}% fired "
|
||
f"| fired a1={fired_a1 / len(fired):.3f} non-fired a1={non_fired_a1_rate:.3f}"
|
||
)
|
||
if not any_fired:
|
||
print(
|
||
" (no activations detected — either the guard wasn't enabled, "
|
||
"evidence_entries weren't persisted, or no cell matched both conditions)"
|
||
)
|
||
return 0
|
||
|
||
|
||
# Detect a B2 guard activation by the downgrade signature. Keep this marker
|
||
# phrase in lockstep with ``false_healthy_guard._DOWNGRADE_ROOT_CAUSE``.
|
||
def _b2_fired(row: dict) -> bool:
|
||
diag = row["run"].get("final_diagnosis") or {}
|
||
rc = _norm(diag.get("root_cause"))
|
||
return "tool observations show unhealthy" in rc and "marked unresolved" in rc
|
||
|
||
|
||
def _cell_a1(row: dict) -> int:
|
||
preds = _top(row["run"])
|
||
return 1 if preds and _is_a1(preds[0], _gt(row["case"])) else 0
|
||
|
||
|
||
def _investigation_a1_hit(row: dict, gt: tuple[str, str, str]) -> int:
|
||
"""1 when opensre's investigation names the GT triple (L0 metric).
|
||
|
||
Primary path: read ``investigation_a1`` from the cell's recorded metrics.
|
||
This is the source of truth — it's what the scorer wrote at run time
|
||
using the full ``case_data`` dict.
|
||
|
||
Fallback (legacy artifacts that pre-date this metric): rebuild a
|
||
pseudo-``case_data`` from ``final_diagnosis`` fields and re-run the
|
||
keyword parser. The fallback is **best-effort** and may undercount the
|
||
scorer's value when ``causal_chain`` / ``validated_claims`` were
|
||
captured at a path the synthesized dict doesn't probe — e.g. directly
|
||
on ``run`` rather than nested inside ``final_diagnosis``. We try both
|
||
locations here to cover the most common artifact shapes, but for
|
||
authoritative L0 numbers on legacy data, re-score the run rather than
|
||
rely on this fallback.
|
||
"""
|
||
scored = _metric(row, "investigation_a1")
|
||
if scored is not None:
|
||
return 1 if scored >= 1.0 else 0
|
||
run = row.get("run", {})
|
||
diag = run.get("final_diagnosis") or {}
|
||
# ``final_state`` may live nested in ``final_diagnosis`` (current shape)
|
||
# or directly on ``run`` (older artifacts). Prefer the nested form when
|
||
# both are present — that's what the scorer would have read first.
|
||
final_state = diag.get("final_state")
|
||
if not isinstance(final_state, dict):
|
||
final_state = run.get("final_state")
|
||
case_data = {
|
||
"root_cause": diag.get("root_cause"),
|
||
"report": diag.get("report"),
|
||
"final_state": final_state if isinstance(final_state, dict) else None,
|
||
}
|
||
payload = infer_final_answer_from_opensre_text(case_data, include_predictor_output=False)
|
||
if not payload:
|
||
return 0
|
||
preds = payload.get("top_3_predictions") or []
|
||
return 1 if preds and _is_a1(preds[0], gt) else 0
|
||
|
||
|
||
def main() -> int:
|
||
if len(sys.argv) != 2:
|
||
print("usage: analyze_validation.py <run-dir-containing-cases/>")
|
||
return 2
|
||
return analyze(Path(sys.argv[1]))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|