bigpowers 2.89.0 → 2.90.0

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.
Files changed (35) hide show
  1. package/.gemini/extensions/bigpowers/gemini-extension.json +1 -1
  2. package/.pi/package.json +1 -1
  3. package/CHANGELOG.md +19 -0
  4. package/package.json +1 -1
  5. package/scripts/ab-sandbox.sh +85 -0
  6. package/scripts/bp-churn-rank.sh +3 -2
  7. package/scripts/check-spec-version-gap.sh +2 -1
  8. package/scripts/land-branch.sh +7 -3
  9. package/scripts/lib/__pycache__/link_utils.cpython-310.pyc +0 -0
  10. package/scripts/lib/__pycache__/srp_engine_links.cpython-310.pyc +0 -0
  11. package/scripts/lib/__pycache__/srp_engine_okf.cpython-310.pyc +0 -0
  12. package/scripts/lib/ab-arm-bigpowers.sh +42 -0
  13. package/scripts/lib/ab-eval.py +285 -0
  14. package/scripts/lib/ab-sandbox/agent.Dockerfile +33 -0
  15. package/scripts/lib/ab-sandbox/filter +6 -0
  16. package/scripts/lib/ab-sandbox/proxy.Dockerfile +13 -0
  17. package/scripts/lib/ab-sandbox/tinyproxy.conf +19 -0
  18. package/scripts/lib/ab_metrics.py +123 -0
  19. package/scripts/lib/ab_progress.py +103 -0
  20. package/scripts/lib/ab_report.py +116 -0
  21. package/scripts/lib/golden-suite-gates.sh +2 -0
  22. package/scripts/lib/parallel-review-worktrees.sh +3 -1
  23. package/scripts/lib/plan-consistency-check.sh +4 -2
  24. package/scripts/lib/run-benchmark.py +30 -16
  25. package/scripts/run-ab-eval.sh +7 -0
  26. package/scripts/run-smoke.sh +3 -2
  27. package/scripts/run-story-verify.sh +7 -0
  28. package/scripts/test-pipefail-pipe-race.sh +68 -0
  29. package/scripts/validate-doctrine.sh +3 -3
  30. package/tests/test_ab_ablation.py +91 -0
  31. package/tests/test_ab_eval.py +309 -0
  32. package/tests/test_ab_pi.py +63 -0
  33. package/tests/test_ab_progress.py +81 -0
  34. package/tests/test_ab_sandbox.py +94 -0
  35. package/tests/test_ab_score.py +113 -0
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env python3
2
+ # story: e58s01 e58s02 e58s03 e58s06
3
+ """A/B eval adapter output parsers: turn an agent CLI's stdout into cost, token and turn counts.
4
+
5
+ claude-json one result object (claude -p --output-format json)
6
+ opencode-jsonl one event per line (opencode run --format json); step_finish carries tokens + cost
7
+ pi-json one event per line (pi -p --mode json); assistant message_end carries usage + cost
8
+ none no metrics
9
+
10
+ tap_score reads `node --test` summaries for a task's partial-credit score (e58s03).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import re
16
+
17
+
18
+ def empty_metrics() -> dict:
19
+ return {"cost_usd": None, "input_tokens": None, "output_tokens": None, "turns": None,
20
+ "models": None, "agent_reported_error": None}
21
+
22
+
23
+ def json_lines(stdout: str) -> list[dict]:
24
+ events = []
25
+ for line in stdout.strip().splitlines():
26
+ try:
27
+ obj = json.loads(line)
28
+ except json.JSONDecodeError:
29
+ continue
30
+ if isinstance(obj, dict):
31
+ events.append(obj)
32
+ return events
33
+
34
+
35
+ def parse_metrics(parser: str, stdout: str) -> dict:
36
+ if parser == "claude-json":
37
+ return parse_claude_json(stdout)
38
+ if parser == "opencode-jsonl":
39
+ return parse_opencode_jsonl(stdout)
40
+ if parser == "pi-json":
41
+ return parse_pi_json(stdout)
42
+ return empty_metrics()
43
+
44
+
45
+ def parse_claude_json(stdout: str) -> dict:
46
+ metrics = empty_metrics()
47
+ result = next((e for e in reversed(json_lines(stdout)) if "num_turns" in e), None)
48
+ if result is None:
49
+ return metrics
50
+ input_tokens, output_tokens = token_totals(result)
51
+ metrics.update({
52
+ "cost_usd": result.get("total_cost_usd"),
53
+ "input_tokens": input_tokens,
54
+ "output_tokens": output_tokens,
55
+ "turns": result.get("num_turns"),
56
+ "models": sorted(result.get("modelUsage") or {}) or None,
57
+ "agent_reported_error": bool(result.get("is_error")),
58
+ })
59
+ return metrics
60
+
61
+
62
+ def token_totals(result: dict) -> tuple[int | None, int | None]:
63
+ """Whole-tree tokens from modelUsage (incl. subagents and cache), else main-loop usage."""
64
+ per_model = [m for m in (result.get("modelUsage") or {}).values() if "inputTokens" in m]
65
+ if per_model:
66
+ inputs = sum(m.get("inputTokens", 0) + m.get("cacheReadInputTokens", 0)
67
+ + m.get("cacheCreationInputTokens", 0) for m in per_model)
68
+ return inputs, sum(m.get("outputTokens", 0) for m in per_model)
69
+ usage = result.get("usage") or {}
70
+ if "input_tokens" not in usage:
71
+ return None, usage.get("output_tokens")
72
+ inputs = (usage["input_tokens"] + usage.get("cache_read_input_tokens", 0)
73
+ + usage.get("cache_creation_input_tokens", 0))
74
+ return inputs, usage.get("output_tokens")
75
+
76
+
77
+ def parse_opencode_jsonl(stdout: str) -> dict:
78
+ """Sum step_finish parts: tokens = {input, output, reasoning, cache: {read, write}}, cost in USD."""
79
+ metrics = empty_metrics()
80
+ events = json_lines(stdout)
81
+ steps = [(e.get("part") or {}) for e in events if e.get("type") == "step_finish"]
82
+ errored = any(e.get("type") == "error" for e in events)
83
+ if not steps:
84
+ metrics["agent_reported_error"] = errored or None
85
+ return metrics
86
+ cost = inputs = outputs = 0.0
87
+ for part in steps:
88
+ tokens = part.get("tokens") or {}
89
+ cache = tokens.get("cache") or {}
90
+ cost += part.get("cost") or 0
91
+ inputs += tokens.get("input", 0) + cache.get("read", 0) + cache.get("write", 0)
92
+ outputs += tokens.get("output", 0) + tokens.get("reasoning", 0)
93
+ metrics.update({"cost_usd": round(cost, 6), "input_tokens": int(inputs), "output_tokens": int(outputs),
94
+ "turns": len(steps), "agent_reported_error": errored})
95
+ return metrics
96
+
97
+
98
+ def parse_pi_json(stdout: str) -> dict:
99
+ metrics = empty_metrics()
100
+ events = json_lines(stdout)
101
+ replies = [e["message"] for e in events if e.get("type") == "message_end"
102
+ and (e.get("message") or {}).get("role") == "assistant"]
103
+ if not replies:
104
+ return metrics
105
+ aborted = any(e.get("type") == "agent_settled" and e.get("aborted") for e in events)
106
+ cost = inputs = outputs = 0.0
107
+ for msg in replies:
108
+ usage = msg.get("usage") or {}
109
+ cost += (usage.get("cost") or {}).get("total") or 0
110
+ inputs += usage.get("input", 0) + usage.get("cacheRead", 0) + usage.get("cacheWrite", 0)
111
+ outputs += usage.get("output", 0)
112
+ errored = aborted or any(m.get("stopReason") in ("error", "aborted") for m in replies)
113
+ metrics.update({"cost_usd": round(cost, 6), "input_tokens": int(inputs), "output_tokens": int(outputs),
114
+ "turns": len(replies), "agent_reported_error": errored})
115
+ return metrics
116
+
117
+
118
+ def tap_score(stdout: str) -> float | None:
119
+ """Share of tests passing over every TAP summary (`node --test --test-reporter=tap`: piped
120
+ output is the spec reporter otherwise). Cancelled tests (timeouts) count as failed."""
121
+ passed = sum(int(n) for n in re.findall(r"^# pass (\d+)", stdout, re.M))
122
+ failed = sum(int(n) for n in re.findall(r"^# (?:fail|cancelled) (\d+)", stdout, re.M))
123
+ return round(passed / (passed + failed), 4) if passed + failed else None
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env python3
2
+ # story: e58s05
3
+ """Live view of an A/B eval while it runs: reads every ab-live.jsonl under the given folders
4
+ (one per shard) and prints runs, pass rate, score and cost per task and arm, each arm's delta
5
+ against vanilla with its bootstrap CI, and how those deltas evolved as runs accumulated.
6
+
7
+ Usage: python3 scripts/lib/ab_progress.py <out-dir>... [--watch SECONDS]
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import sys
14
+ import time
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+
18
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
19
+ from ab_report import aggregate, mean # type: ignore[import] # noqa: E402
20
+
21
+ CHECKPOINTS = (5, 10, 20, 30, 50, 75, 100)
22
+
23
+
24
+ def load(dirs: list[Path]) -> list[dict]:
25
+ records = []
26
+ for folder in dirs:
27
+ for live in sorted(folder.rglob("ab-live.jsonl")):
28
+ records += [json.loads(line) for line in live.read_text().splitlines() if line.strip()]
29
+ return records
30
+
31
+
32
+ def ordered(values: list[str], preferred: str) -> list[str]:
33
+ unique = sorted(set(values))
34
+ return [preferred] + [v for v in unique if v != preferred] if preferred in unique else unique
35
+
36
+
37
+ def fmt(value, places=2) -> str:
38
+ return "-" if value is None else f"{value:.{places}f}"
39
+
40
+
41
+ def table(records: list[dict], tasks: list[str], arms: list[str]) -> list[str]:
42
+ lines = [f"{'task':<8} {'arm':<10} {'runs':>5} {'pass':>6} {'score':>6} {'cost$':>7} {'errors':>6}"]
43
+ for task in tasks:
44
+ for arm in arms:
45
+ runs = [r for r in records if r["task"] == task and r["arm"] == arm]
46
+ if runs:
47
+ lines.append(f"{task:<8} {arm:<10} {len(runs):>5} {fmt(mean([float(r['pass']) for r in runs])):>6} "
48
+ f"{fmt(mean([r.get('score') for r in runs])):>6} {fmt(mean([r['cost_usd'] for r in runs]), 3):>7} "
49
+ f"{sum(r['agent_error'] for r in runs):>6}")
50
+ return lines
51
+
52
+
53
+ def delta_line(name: str, d: dict) -> str:
54
+ line = f" {name} - vanilla: pass {d['pass_rate']:+.2f} {d['ci95']}"
55
+ if "score" in d:
56
+ line += f" score {d['score']:+.3f} {d['score_ci95']}"
57
+ return line
58
+
59
+
60
+ def evolution(records: list[dict], tasks: list[str], arms: list[str]) -> list[str]:
61
+ """Deltas using only runs with index <= each checkpoint: shows the CI narrowing over time."""
62
+ top = max(r["run"] for r in records)
63
+ lines = []
64
+ for cp in [c for c in CHECKPOINTS if c < top] + [top]:
65
+ subset = [r for r in records if r["run"] <= cp]
66
+ for name, d in aggregate(subset, arms, tasks)["deltas_vs_vanilla"].items():
67
+ lines.append(f" runs<={cp:<4}{delta_line(name, d)[1:]}")
68
+ return lines
69
+
70
+
71
+ def render(records: list[dict]) -> str:
72
+ tasks = sorted({r["task"] for r in records})
73
+ arms = ordered([r["arm"] for r in records], "vanilla")
74
+ models = sorted({str(r.get("model")) for r in records})
75
+ out = [f"A/B progress — {len(records)} runs, model {', '.join(models)}, at {datetime.now():%H:%M:%S}", ""]
76
+ out += table(records, tasks, arms)
77
+ result = aggregate(records, arms, tasks)
78
+ if result["deltas_vs_vanilla"]:
79
+ out += ["", "deltas (all runs so far, 95% CI):"]
80
+ out += [delta_line(n, d) for n, d in result["deltas_vs_vanilla"].items()]
81
+ out += ["", "evolution (pass and score delta by run index):"] + evolution(records, tasks, arms)
82
+ return "\n".join(out)
83
+
84
+
85
+ def main(argv: list[str] | None = None) -> int:
86
+ p = argparse.ArgumentParser(description="Live A/B eval progress across shards")
87
+ p.add_argument("dirs", nargs="+", type=Path)
88
+ p.add_argument("--watch", type=int, help="repeat every N seconds")
89
+ args = p.parse_args(argv)
90
+ while True:
91
+ records = load(args.dirs)
92
+ if not records:
93
+ print("no ab-live.jsonl found under: " + ", ".join(map(str, args.dirs)))
94
+ return 2
95
+ print(render(records), flush=True)
96
+ if not args.watch:
97
+ return 0
98
+ time.sleep(args.watch)
99
+ print()
100
+
101
+
102
+ if __name__ == "__main__":
103
+ sys.exit(main())
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env python3
2
+ # story: e58s01 e58s03 e58s04
3
+ """A/B eval aggregation and reports — pure functions over run records (no agent, no subprocess
4
+ except reading the bigpowers git SHA). Split from ab-eval.py to keep both under the 300-line cap."""
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import random
9
+ import subprocess
10
+ from datetime import date
11
+ from pathlib import Path
12
+
13
+ import yaml
14
+
15
+ REPO_ROOT = Path(__file__).resolve().parents[2]
16
+ BOOTSTRAP_RESAMPLES = 1000
17
+ BOOTSTRAP_SEED = 58
18
+
19
+
20
+ # ── aggregation ──────────────────────────────────────────────────────────────
21
+
22
+ def mean(values: list) -> float | None:
23
+ vals = [v for v in values if v is not None]
24
+ return round(sum(vals) / len(vals), 4) if vals else None
25
+
26
+
27
+ def bootstrap_delta_ci(treatment: list[float], control: list[float]) -> list[float]:
28
+ """Seeded 95% CI of mean(treatment) - mean(control); bools or scores in [0, 1]."""
29
+ rng = random.Random(BOOTSTRAP_SEED)
30
+ deltas = []
31
+ for _ in range(BOOTSTRAP_RESAMPLES):
32
+ t = [rng.choice(treatment) for _ in treatment]
33
+ c = [rng.choice(control) for _ in control]
34
+ deltas.append(sum(t) / len(t) - sum(c) / len(c))
35
+ deltas.sort()
36
+ lo = deltas[int(0.025 * BOOTSTRAP_RESAMPLES)]
37
+ hi = deltas[int(0.975 * BOOTSTRAP_RESAMPLES) - 1]
38
+ return [round(lo, 4), round(hi, 4)]
39
+
40
+
41
+ def arm_summary(runs: list[dict]) -> dict:
42
+ return {
43
+ "runs": len(runs),
44
+ "pass_rate": mean([float(r["pass"]) for r in runs]),
45
+ "mean_score": mean([r.get("score") for r in runs]),
46
+ "tamper_rate": mean([float(r["tampered"]) for r in runs]),
47
+ "agent_error_rate": mean([float(r["agent_error"]) for r in runs]),
48
+ "mean_cost_usd": mean([r["cost_usd"] for r in runs]),
49
+ "mean_input_tokens": mean([r["input_tokens"] for r in runs]),
50
+ "mean_output_tokens": mean([r["output_tokens"] for r in runs]),
51
+ "mean_turns": mean([r["turns"] for r in runs]),
52
+ "mean_duration_s": mean([r["duration_s"] for r in runs]),
53
+ "mean_diff_lines": mean([r["diff_lines"] for r in runs]),
54
+ }
55
+
56
+
57
+ def aggregate(records: list[dict], arms: list[str], tasks: list[str]) -> dict:
58
+ by_arm = {a: [r for r in records if r["arm"] == a] for a in arms}
59
+ summary = {a: arm_summary(runs) for a, runs in by_arm.items()}
60
+ per_task = {t: {a: mean([float(r["pass"]) for r in by_arm[a] if r["task"] == t]) for a in arms}
61
+ for t in tasks}
62
+ per_task_score = {t: {a: mean([r.get("score") for r in by_arm[a] if r["task"] == t]) for a in arms}
63
+ for t in tasks}
64
+ deltas = {a: arm_delta(by_arm, summary, a) for a in arms if a != "vanilla"} if "vanilla" in arms else {}
65
+ return {"arms_summary": summary, "per_task": per_task, "per_task_score": per_task_score,
66
+ "delta": deltas.get("bigpowers"), "deltas_vs_vanilla": deltas}
67
+
68
+
69
+ def arm_delta(by_arm: dict, summary: dict, arm: str) -> dict:
70
+ """arm - vanilla: pass rate and score with seeded bootstrap CIs, tamper rate and cost (e58s04)."""
71
+ tr, va = summary[arm], summary["vanilla"]
72
+ cost = None
73
+ if tr["mean_cost_usd"] is not None and va["mean_cost_usd"] is not None:
74
+ cost = round(tr["mean_cost_usd"] - va["mean_cost_usd"], 4)
75
+ delta = {
76
+ "pass_rate": round(tr["pass_rate"] - va["pass_rate"], 4),
77
+ "ci95": bootstrap_delta_ci([r["pass"] for r in by_arm[arm]], [r["pass"] for r in by_arm["vanilla"]]),
78
+ "tamper_rate": round(tr["tamper_rate"] - va["tamper_rate"], 4),
79
+ "cost_usd": cost,
80
+ }
81
+ scores = {a: [r["score"] for r in by_arm[a] if r.get("score") is not None] for a in (arm, "vanilla")}
82
+ if scores[arm] and scores["vanilla"]:
83
+ delta["score"] = round(tr["mean_score"] - va["mean_score"], 4)
84
+ delta["score_ci95"] = bootstrap_delta_ci(scores[arm], scores["vanilla"])
85
+ return delta
86
+
87
+
88
+ # ── reporting ────────────────────────────────────────────────────────────────
89
+
90
+ def bigpowers_sha() -> str:
91
+ res = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=REPO_ROOT, capture_output=True, text=True)
92
+ return res.stdout.strip() or "unknown"
93
+
94
+
95
+ def write_reports(out: Path, meta: dict, result: dict, records: list[dict]) -> Path:
96
+ out.mkdir(parents=True, exist_ok=True)
97
+ today = date.today().isoformat()
98
+ report = out / f"AB-{today}.yaml"
99
+ report.write_text(yaml.safe_dump({**meta, **result}, sort_keys=False))
100
+ with (out / f"ab-runs-{today}.jsonl").open("w") as f:
101
+ for rec in records:
102
+ f.write(json.dumps({**rec, "bigpowers_sha": meta["bigpowers_sha"]}) + "\n")
103
+ return report
104
+
105
+
106
+ def print_summary(result: dict) -> None:
107
+ print(f"{'arm':<10} {'pass':>6} {'score':>6} {'tamper':>7} {'cost$':>8} {'turns':>6} {'secs':>7}")
108
+ for arm, s in result["arms_summary"].items():
109
+ fmt = lambda v, p=2: "-" if v is None else f"{v:.{p}f}"
110
+ print(f"{arm:<10} {fmt(s['pass_rate']):>6} {fmt(s['mean_score']):>6} {fmt(s['tamper_rate']):>7} "
111
+ f"{fmt(s['mean_cost_usd'], 3):>8} {fmt(s['mean_turns'], 1):>6} {fmt(s['mean_duration_s'], 0):>7}")
112
+ for arm, d in result.get("deltas_vs_vanilla", {}).items():
113
+ line = f"delta {arm:<10} - vanilla: pass {d['pass_rate']:+.2f} CI {d['ci95']}"
114
+ if "score" in d:
115
+ line += f" score {d['score']:+.2f} CI {d['score_ci95']}"
116
+ print(line)
@@ -79,6 +79,8 @@ GOLDEN_GATES=(
79
79
  "specs-parse:bash scripts/validate-specs-yaml.sh:false"
80
80
  # The runner itself: failing output is shown, skips are named and reasoned.
81
81
  "golden-gate-runner:bash scripts/test-golden-gate-runner.sh:false"
82
+ # Pipefail SIGPIPE races: fix shapes hold, git output never feeds grep -q/head.
83
+ "pipefail-pipe-race:bash scripts/test-pipefail-pipe-race.sh:false"
82
84
  # One pipeline, release needs: every gate, pinned runner and actions.
83
85
  "ci-pipeline-contract-selftest:bash scripts/test-ci-pipeline-contract.sh --self-test:false"
84
86
  "ci-pipeline-contract:bash scripts/test-ci-pipeline-contract.sh:false"
@@ -11,7 +11,9 @@ cd "$REPO_ROOT"
11
11
  BASE=".bigpowers/worktrees/review-${CHECK}"
12
12
  mkdir -p "$(dirname "$BASE")"
13
13
 
14
- if git worktree list | grep -q "$BASE"; then
14
+ # Capture first: `git worktree list | grep -q` is a pipefail race (BUG-2026-11-02-pipefail-pipe-race).
15
+ WORKTREES="$(git worktree list)"
16
+ if grep -q "$BASE" <<<"$WORKTREES"; then
15
17
  git worktree remove --force "$BASE" 2>/dev/null || true
16
18
  fi
17
19
 
@@ -69,7 +69,7 @@ for tasks in "${TASKS[@]+"${TASKS[@]}"}"; do
69
69
  sid="$(echo "$base" | grep -oE 'e[0-9]+s[0-9]+' | head -1)"
70
70
  [[ -n "$sid" ]] || continue
71
71
 
72
- spec_name="$(grep -E '^spec:' "$tasks" | head -1 | sed -E 's/^spec:[[:space:]]*//; s/^"(.*)"$/\1/')"
72
+ spec_name="$(grep -E '^spec:' "$tasks" | head -1 | sed -E 's/^spec:[[:space:]]*//; s/^"(.*)"$/\1/' || true)"
73
73
  spec="$CAPSULE/$spec_name"
74
74
  if [[ -z "$spec_name" || ! -f "$spec" ]]; then
75
75
  report CRITICAL "Story $sid tasks.yaml declares no valid spec: file"
@@ -92,7 +92,9 @@ for tasks in "${TASKS[@]+"${TASKS[@]}"}"; do
92
92
  report CRITICAL "Story $sid tasks.yaml missing runnable verify: commands"
93
93
  else
94
94
  verify_cmd="$("$PYTHON_BIN" "$SCRIPT_LIB_DIR/extract-story-verify.py" "$REPO_ROOT" 2>/dev/null \
95
- | awk -F'\t' -v s="$sid" '$1 == s { print $4; exit }')"
95
+ | awk -F'\t' -v s="$sid" '$1 == s && !found { print $4; found = 1 }')"
96
+ # awk reads to the end: exiting early closes the pipe, Python fails to flush and exits 120,
97
+ # and pipefail turns that into a random failure of this check.
96
98
  if [[ -n "$verify_cmd" ]]; then
97
99
  is_executable_verify "$verify_cmd" \
98
100
  || report CRITICAL "Story $sid verify: is prose, not a runnable command: $verify_cmd"
@@ -60,26 +60,44 @@ def scenario_pass_rate(scenario: dict, runs: int, with_skill: bool) -> tuple[flo
60
60
  if not command:
61
61
  return (-1.0, "missing_command")
62
62
  if not with_skill:
63
- command = f"test -f /dev/null" # bare agent: code graders cannot pass without skill context
63
+ # No agent runs here, so there is no honest without-skill number (e58s01).
64
+ # Real with/without deltas come from scripts/run-ab-eval.sh.
65
+ return (-1.0, "unmeasured")
64
66
  passes = sum(1 for _ in range(runs) if run_code_grader(command, REPO_ROOT))
65
67
  return (passes / runs, "code")
66
68
 
67
69
 
68
- def pass_at_k(scenarios: list[dict], runs: int, with_skill: bool) -> float:
70
+ def pass_at_k(scenarios: list[dict], runs: int, with_skill: bool) -> float | None:
69
71
  total_weight = 0.0
70
72
  weighted = 0.0
71
73
  for sc in scenarios:
72
74
  rate, status = scenario_pass_rate(sc, runs, with_skill)
73
- if status == "pending_agent_eval":
75
+ if status in ("pending_agent_eval", "unmeasured"):
74
76
  continue
75
77
  weight = float(sc.get("weight", 1.0))
76
78
  total_weight += weight
77
79
  weighted += weight * max(rate, 0.0)
78
80
  if total_weight == 0:
79
- return 0.0
81
+ return None
80
82
  return round(weighted / total_weight, 2)
81
83
 
82
84
 
85
+ def delta_of(with_score: float | None, without_score: float | None) -> float | None:
86
+ if with_score is None or without_score is None:
87
+ return None
88
+ return round(with_score - without_score, 2)
89
+
90
+
91
+ def delta_summary(val_delta: float | None) -> str:
92
+ if val_delta is None:
93
+ return "UNMEASURED: no without-skill agent run — use scripts/run-ab-eval.sh for a real delta"
94
+ if val_delta < 0:
95
+ return f"REGRESSION: validation Δ {val_delta} — do NOT ship"
96
+ if val_delta < 0.05:
97
+ return f"WARN: validation Δ {val_delta} — marginal contribution"
98
+ return f"OK: validation Δ {val_delta}"
99
+
100
+
83
101
  def partition_scenarios(scenarios: list[dict]) -> tuple[list[dict], list[dict]]:
84
102
  train, validation = [], []
85
103
  for sc in scenarios:
@@ -93,13 +111,13 @@ def partition_scenarios(scenarios: list[dict]) -> tuple[list[dict], list[dict]]:
93
111
 
94
112
  def build_split_report(scenarios: list[dict], runs: int) -> dict:
95
113
  if not scenarios:
96
- return {"with_skill": 0.0, "without_skill": 0.0, "delta": 0.0, "scenarios": []}
114
+ return {"with_skill": None, "without_skill": None, "delta": None, "scenarios": []}
97
115
  with_score = pass_at_k(scenarios, runs, with_skill=True)
98
116
  without_score = pass_at_k(scenarios, runs, with_skill=False)
99
117
  return {
100
118
  "with_skill": with_score,
101
119
  "without_skill": without_score,
102
- "delta": round(with_score - without_score, 2),
120
+ "delta": delta_of(with_score, without_score),
103
121
  "scenarios": [sc["id"] for sc in scenarios],
104
122
  }
105
123
 
@@ -119,13 +137,15 @@ def scenario_details(scenarios: list[dict], runs: int) -> list[dict]:
119
137
  }
120
138
  )
121
139
  continue
140
+ with_value = round(max(with_rate, 0.0), 2)
141
+ without_value = None if without_rate < 0 else round(without_rate, 2)
122
142
  rows.append(
123
143
  {
124
144
  "id": sc["id"],
125
145
  "split": sc.get("split", "validation"),
126
- "with_pass_rate": round(max(with_rate, 0.0), 2),
127
- "without_pass_rate": round(max(without_rate, 0.0), 2),
128
- "delta": round(max(with_rate, 0.0) - max(without_rate, 0.0), 2),
146
+ "with_pass_rate": with_value,
147
+ "without_pass_rate": without_value,
148
+ "delta": delta_of(with_value, without_value),
129
149
  "weight": sc.get("weight", 1.0),
130
150
  }
131
151
  )
@@ -180,13 +200,7 @@ def run_benchmark(skill: str, baseline: bool = False) -> dict:
180
200
  shutil.copy(yaml_path, REPORTS_DIR / f"BASELINE-{skill}.yaml")
181
201
  shutil.copy(json_path, REPORTS_DIR / f"baseline-{skill}.json")
182
202
  print(f"Pinned baseline for {skill}")
183
- val_delta = report["validation"]["delta"]
184
- if val_delta < 0:
185
- print(f"REGRESSION: validation Δ {val_delta} — do NOT ship")
186
- elif val_delta < 0.05:
187
- print(f"WARN: validation Δ {val_delta} — marginal contribution")
188
- else:
189
- print(f"OK: validation Δ {val_delta}")
203
+ print(delta_summary(report["validation"]["delta"]))
190
204
  return report
191
205
 
192
206
 
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env bash
2
+ # story: e58s01
3
+ # run-ab-eval.sh — run the same tasks with and without bigpowers; grade only the outcome.
4
+ # Advisory: exits 0 after a completed run, 2 on configuration errors. See specs/benchmarks/SCHEMA.md.
5
+ set -euo pipefail
6
+ source "$(dirname "${BASH_SOURCE[0]}")/lib/python-env.sh"
7
+ exec "$PYTHON" "$(dirname "${BASH_SOURCE[0]}")/lib/ab-eval.py" "$@"
@@ -46,7 +46,8 @@ run_single_url_check() {
46
46
 
47
47
  # Assert content signal
48
48
  if [ -n "$content_signal" ]; then
49
- if echo "$body" | grep -qiE "$content_signal"; then
49
+ # Here-string, not `echo "$body" | grep -q`: a large body races pipefail (BUG-2026-11-02-pipefail-pipe-race).
50
+ if grep -qiE "$content_signal" <<<"$body"; then
50
51
  echo " PASS: body matches \"$content_signal\""
51
52
  else
52
53
  echo " FAIL: body does not match \"$content_signal\""
@@ -165,7 +166,7 @@ run_parsed_check() {
165
166
 
166
167
  # Assert content signal
167
168
  if [ -n "$content_signal" ]; then
168
- if echo "$body" | grep -qiE "$content_signal"; then
169
+ if grep -qiE "$content_signal" <<<"$body"; then
169
170
  echo " PASS: body matches \"$content_signal\""
170
171
  checks_passed=$((checks_passed + 1))
171
172
  else
@@ -82,6 +82,13 @@ run_story_verify() {
82
82
  fi
83
83
  echo "FAIL: $sid [$src]$note — $cmd"
84
84
  echo " output: $(echo "$RUN_OUT" | tail -3 | tr '\n' ' ')"
85
+ # Name the failing test. unittest/pytest footers ("FAILED (failures=1)") say a test
86
+ # broke but not which one, so a red gate is undiagnosable from CI logs alone
87
+ # (BUG-2026-11-03-story-verify-output-hides-failing-test).
88
+ local detail
89
+ detail="$(echo "$RUN_OUT" | grep -E '^(FAIL|ERROR): |\.\.\. (FAIL|ERROR)$|^AssertionError|^[A-Za-z]*Error: |^Traceback' \
90
+ | head -3 | tr '\n' ' ')"
91
+ [ -n "$detail" ] && echo " detail: $detail"
85
92
  FAIL=$((FAIL + 1))
86
93
  return 1
87
94
  }
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env bash
2
+ # story: BUG-2026-11-02-pipefail-pipe-race
3
+ # test-pipefail-pipe-race.sh — regression guard for SIGPIPE races under pipefail.
4
+ #
5
+ # `producer | grep -q` (or `| head`) fails at random under `set -o pipefail`:
6
+ # the reader exits early, the producer dies with SIGPIPE (141), and pipefail
7
+ # reports the pipeline as failed — a found match reads as "not found".
8
+ #
9
+ # (a) anti-vacuity: the racy shape really fails on this host
10
+ # (b) each fix shape used in the repo passes RUNS times with a long producer
11
+ # (c) static guard: no pipefail script pipes git output into an early-exit reader
12
+ #
13
+ # Usage: bash scripts/test-pipefail-pipe-race.sh
14
+
15
+ set -euo pipefail
16
+ source "$(dirname "${BASH_SOURCE[0]}")/lib/skill-common.sh"
17
+ resolve_repo_root
18
+ cd "$REPO_ROOT"
19
+
20
+ source scripts/lib/test-assertions.sh
21
+ TA_PASS=0
22
+ TA_FAIL=0
23
+ RUNS=20
24
+
25
+ # ~2 MB of output: far past any pipe buffer, so an early reader always wins.
26
+ long_producer() { seq 1 300000; }
27
+
28
+ # Runs "$@" RUNS times; prints how many runs returned non-zero.
29
+ count_failures() {
30
+ local i fails=0
31
+ for ((i = 0; i < RUNS; i++)); do
32
+ "$@" || fails=$((fails + 1))
33
+ done
34
+ echo "$fails"
35
+ }
36
+
37
+ racy_grep_q() { long_producer | grep -q '^1$'; }
38
+ fixed_capture() { local out; out="$(long_producer)"; grep -q '^1$' <<<"$out"; }
39
+ fixed_nonempty() { [[ -n "$(long_producer)" ]]; }
40
+ fixed_awk_limit() { long_producer | awk -v limit=3 'NR <= limit' >/dev/null; }
41
+ fixed_sed_range() { long_producer | sed -n '1,5p' >/dev/null; }
42
+
43
+ echo "=== test-pipefail-pipe-race.sh ==="
44
+
45
+ fails=$(count_failures racy_grep_q)
46
+ [[ "$fails" -gt 0 ]] \
47
+ && ta_pass "(a) racy 'producer | grep -q' fails under pipefail ($fails/$RUNS)" \
48
+ || ta_fail "(a) racy shape never failed — test cannot detect the race on this host"
49
+
50
+ for fix in fixed_capture fixed_nonempty fixed_awk_limit fixed_sed_range; do
51
+ fails=$(count_failures "$fix")
52
+ [[ "$fails" -eq 0 ]] \
53
+ && ta_pass "(b) $fix: 0/$RUNS failures" \
54
+ || ta_fail "(b) $fix: $fails/$RUNS failures"
55
+ done
56
+
57
+ # run-skill-verify.sh holds `git diff ... | head -20` as a fixture string of
58
+ # fail-open verify directives, not as a command it runs.
59
+ hits=$(grep -rlE 'pipefail' scripts hooks --include='*.sh' \
60
+ | grep -v '^scripts/run-skill-verify\.sh$' \
61
+ | xargs grep -nE 'git [a-z-]+[^|]*\|[[:space:]]*(grep -[a-zA-Z]*[qm]|head)' \
62
+ | grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' || true)
63
+ [[ -z "$hits" ]] \
64
+ && ta_pass "(c) no pipefail script pipes git output into grep -q/-m or head" \
65
+ || ta_fail "(c) git output piped into an early-exit reader:"$'\n'"$hits"
66
+
67
+ echo "$TA_PASS passed, $TA_FAIL failed"
68
+ [[ "$TA_FAIL" -eq 0 ]]
@@ -55,7 +55,7 @@ else
55
55
  grep -rE "PLAN\.md|STATE\.md|PROJECT\.md|CONTEXT\.md|SUMMARY\.md|VERIFICATION\.md|RESEARCH\.md|RELEASE-PLAN\.md" \
56
56
  docs/ 2>/dev/null \
57
57
  | grep -v "docs/archive/" \
58
- | grep -v "specs/archive" | head -20 >&2
58
+ | grep -v "specs/archive" | sed -n '1,20p' >&2 # sed reads to the end; `head` races pipefail (BUG-2026-11-02-pipefail-pipe-race)
59
59
  fi
60
60
 
61
61
  # ── Epic 1: Skill count consistency ───────────────────────────────────────────
@@ -94,7 +94,7 @@ else
94
94
  2>/dev/null \
95
95
  | grep -v "specs/archive\|specs/epics/archive\|docs/archive/\|\.git\|node_modules\|\.gemini\|\.cursor" \
96
96
  | grep -v "CHANGELOG\.md\|specs/verifications/reports/\|PLAN-evolve-structure\.md" \
97
- | head -10 >&2
97
+ | sed -n '1,10p' >&2
98
98
  fi
99
99
 
100
100
  # ── Epic 3: BCP slop guard ────────────────────────────────────────────────────
@@ -107,7 +107,7 @@ if [[ "$BCP_SLOP" -eq 0 ]]; then
107
107
  else
108
108
  doctrine_fail "'Build Commit Point' found ($BCP_SLOP hits) — canonical term is 'Business Complexity Points'; see docs/references/bcp.md"
109
109
  grep -ri "build commit point" . 2>/dev/null \
110
- | grep -v "specs/archive\|\.git\|node_modules\|\.gemini\|\.cursor\|validate-doctrine\.sh" | head -5 >&2
110
+ | grep -v "specs/archive\|\.git\|node_modules\|\.gemini\|\.cursor\|validate-doctrine\.sh" | sed -n '1,5p' >&2
111
111
  fi
112
112
 
113
113
  # ── Epic 4: SKILL.md size cap ─────────────────────────────────────────────────