arborist/bench/scripts/fivef_threshold_calibration.py
russell@unturf.com 3ea27aa471
#000047 — close: delta_aggregator knob on ForkScore (Option D)
The #000025 §10.14 calibration showed _delta_5{s,t,f} mean over a
battery's 5 subs, so a single-sub gain weighs 1/5 of face value (the
5× dilution). #000047 ships the knob to pick the aggregation, default
unchanged.

WeightSet.delta_aggregator ∈ {"mean","max","sum"} (default "mean") —
a categorical field, validated in __post_init__ against
DELTA_AGGREGATORS; from_dict takes it as a string. Default unchanged →
ScoredFork output byte-identical → no fork_score.ESTIMATOR_VERSION
bump.

fork_score._aggregate(deltas, how): mean = arithmetic mean, max =
max(0.0, max_i Δ_i), sum = Σ Δ_i; empty → 0.0. _delta_5s/_delta_5t/
_delta_5f take an aggregator arg (default "mean"); the 5F efficiency
bonus is added after the aggregated base (aggregator-independent).
fork_score passes weights.delta_aggregator. The per-sub
HARD_REGRESSION_FLOOR flags are computed before aggregation, so a
single-sub regression still forces REJECT under max/sum. The chosen
aggregator is recorded in ScoredFork.weights["delta_aggregator"] (via
WeightSet.as_dict()); fork_score_branches traceability stays via the
opaque weights_id — no schema migration.

bench/scripts/fivef_threshold_calibration.py gained §5 — runs the
#000046 below-ceiling pack (5f/falsification at 0.333) and shows the
verdict / γ·Δ5f under each aggregator; bench/results/5f-threshold-
calibration-2026-05-11.md §5 is the captured record. Default stays
"mean" — the conservative, noise-robust, regression-symmetric choice
matching docs/bench-maxing.md's per-rate floor framing; v8 picks
max/sum per-deployment.

Tests: 8 new in tests/test_fork_score.py + 1 anchor in
tests/test_fivef_threshold_calibration.py; tests/test_weights.py
as_dict field-set test updated to include delta_aggregator;
test_fork_score.py AUTOCOUNT tags (#000012 §286, warrant-substrate-
cookbook.md ×2) bumped 23 → 31.

#000047 closed; #000012 §8 §3 + TICKETS.md row updated.
Full suite: 2330 passed, 28 skipped.
2026-05-11 08:27:38 -04:00

399 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""5S/5T/5F → v8 ForkScore threshold-calibration handoff (#000025 §10.14).
Closure deliverable for ticket #000025 §10.14 ("threshold calibration for
v8 selection acceptance handed off to #000012"). Pure measurement — runs
the canonical 5S / 5T / 5F sub-batteries (embedded packs + the 5F live
packs), builds the parent/child metrics bundle the ForkScore reads
(:func:`arborist.substrate.fork_score.bench_result_to_metrics` shape),
and reports:
1. Per-pack baseline rate (the ``_BATTERY_RATE_KEYS`` metric the scorer
consumes) + fixture count + 1/n observability granularity.
2. The identity-fork verdict — ``fork_score(parent, parent)`` — which
tells #000012 what "no change" scores to (it's MARGINAL, not
ACCEPT, because every Δ-rate term is 0).
3. Synthetic-perturbation verdicts: a single +``SIGNAL_FLOOR`` bump on
one 5F sub-battery → ACCEPT; a single ``HARD_REGRESSION_FLOOR``
drop → REJECT. Confirms the floor constants do what the docstrings
claim.
4. A recommendation block: are ``SIGNAL_FLOOR`` / ``HARD_REGRESSION_FLOOR``
(both 0.05 today) appropriate given the observed pack granularity?
No mutation, no LLM call, no schema / governance-hash / canonicalization
change. The markdown report is the deliverable; #000012 cites it.
"""
from __future__ import annotations
import argparse
import statistics
import sys
from dataclasses import asdict
from datetime import datetime, timezone
from pathlib import Path
# bench/ is on sys.path when run as a module (python -m); when run as a
# script, add the repo root so `import bench...` and `import arborist...`
# resolve.
_REPO_ROOT = Path(__file__).resolve().parents[2]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
from bench.batteries import b_5f, b_5s, b_5t # noqa: E402
from bench.batteries.base import BatteryResult # noqa: E402
from arborist.substrate.fork_score import ( # noqa: E402
HARD_REGRESSION_FLOOR,
SIGNAL_FLOOR,
_BATTERY_RATE_KEYS,
bench_result_to_metrics,
fork_score,
)
from arborist.substrate.weights import DELTA_AGGREGATORS, WeightSet # noqa: E402
_FIX = _REPO_ROOT / "bench" / "fixtures"
# The canonical sub-battery → fixture-pack map the ForkScore Δ-rate terms
# read. Mirrors bench.batteries.runner._DEFAULT_FIXTURES but restricted
# to the keys present in fork_score._BATTERY_RATE_KEYS (so legacy 5t
# "transfer" is excluded — the scorer drops it). The "live" 5F packs are
# included as a separate column because their runners exercise real
# arborist surfaces on temp shards (Phase 1b.2).
_EMBEDDED_PACKS: dict[tuple[str, str], Path] = {
("5s", "syntax"): _FIX / "5s" / "syntax-v1.jsonl",
("5s", "semantics"): _FIX / "5s" / "semantics-v1.jsonl",
("5s", "syllogism"): _FIX / "5s" / "syllogism-v1.jsonl",
("5s", "synthesis"): _FIX / "5s" / "synthesis-v1.jsonl",
("5s", "semiotics"): _FIX / "5s" / "semiotics-v1.jsonl",
("5t", "transfer-learning"): _FIX / "5t" / "transfer-learning-v2.jsonl",
("5t", "triangulation"): _FIX / "5t" / "triangulation-v1.jsonl",
("5t", "truthtables"): _FIX / "5t" / "truthtables-v1.jsonl",
("5t", "transitivity"): _FIX / "5t" / "transitivity-v1.jsonl",
("5t", "time"): _FIX / "5t" / "time-v1.jsonl",
("5f", "function"): _FIX / "5f" / "function-v1.jsonl",
("5f", "finetuning"): _FIX / "5f" / "finetuning-v1.jsonl",
("5f", "falsification"): _FIX / "5f" / "falsification-v1.jsonl",
("5f", "formulate"): _FIX / "5f" / "formulate-v1.jsonl",
("5f", "feedback-loop"): _FIX / "5f" / "feedback-loop-v1.jsonl",
}
_LIVE_PACKS: dict[tuple[str, str], Path] = {
("5f", "function"): _FIX / "5f" / "function-live-v1.jsonl",
("5f", "finetuning"): _FIX / "5f" / "finetuning-live-v1.jsonl",
("5f", "falsification"): _FIX / "5f" / "falsification-live-v1.jsonl",
("5f", "formulate"): _FIX / "5f" / "formulate-live-v1.jsonl",
("5f", "feedback-loop"): _FIX / "5f" / "feedback-loop-live-v1.jsonl",
}
_SUB_RUNNERS = {"5s": b_5s.SUB_BATTERIES, "5t": b_5t.SUB_BATTERIES, "5f": b_5f.SUB_BATTERIES}
def _run_pack(battery: str, sub: str, path: Path) -> BatteryResult:
return _SUB_RUNNERS[battery][sub](path)
def _results_payload(packs: dict[tuple[str, str], Path]) -> dict:
"""Run every pack and shape the output like bench.batteries.runner."""
results = []
for (battery, sub), path in packs.items():
r = _run_pack(battery, sub, path)
results.append(asdict(r))
return {"schema_version": "bench-result-v1", "results": results}
def _rate_of(metrics: dict, battery: str, sub: str) -> float | None:
key = _BATTERY_RATE_KEYS.get(battery, {}).get(sub)
if key is None:
return None
v = metrics.get(key)
return float(v) if v is not None else None
def _deepcopy_bundle(metrics_bundle: dict) -> dict:
return {b: {s: dict(m) for s, m in subs.items()} for b, subs in metrics_bundle.items()}
def _set_rate(bundle: dict, battery: str, sub: str, value: float) -> None:
key = _BATTERY_RATE_KEYS[battery][sub]
bundle.setdefault(battery, {}).setdefault(sub, {})[key] = max(0.0, min(1.0, value))
def _shift_rate(bundle: dict, battery: str, sub: str, delta: float) -> None:
key = _BATTERY_RATE_KEYS[battery][sub]
base = float(bundle.get(battery, {}).get(sub, {}).get(key, 0.0))
_set_rate(bundle, battery, sub, base + delta)
def _degraded_parent(parent: dict, knock_to: float = 0.90) -> dict:
"""A synthetic parent with every 5F sub-battery rate knocked down to
``knock_to`` — gives a child headroom to improve. The production
parent is at ceiling (rate 1.0 on every pack), so threshold
mechanics can only be exercised against a below-ceiling baseline."""
out = _deepcopy_bundle(parent)
for sub in _BATTERY_RATE_KEYS["5f"]:
_set_rate(out, "5f", sub, knock_to)
return out
def _fmt_verdict(sf) -> str:
flags = f" — flags: {', '.join(sf.flags)}" if sf.flags else ""
return f"{sf.verdict} (score {sf.score:+.4f}){flags}"
def build_report() -> str:
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
embedded = _results_payload(_EMBEDDED_PACKS)
live = _results_payload(_LIVE_PACKS)
parent = bench_result_to_metrics(embedded)
live_metrics = bench_result_to_metrics(live)
# ---- per-pack table rows -------------------------------------------------
rows = []
granularities: list[float] = []
for r in embedded["results"]:
battery, sub = r["battery"], r["sub_battery"]
n = r["pass_count"] + r["fail_count"]
rate = _rate_of(r["metrics"], battery, sub)
gran = (1.0 / n) if n else float("nan")
if n:
granularities.append(gran)
live_r = next(
(lr for lr in live["results"] if lr["battery"] == battery and lr["sub_battery"] == sub),
None,
)
live_rate = _rate_of(live_r["metrics"], battery, sub) if live_r else None
rows.append((battery, sub, n, rate, gran, live_rate))
coarsest = max(granularities) if granularities else float("nan")
finest = min(granularities) if granularities else float("nan")
# ---- fork-score sanity verdicts ------------------------------------------
# The production parent is at ceiling (rate 1.0 everywhere), so a
# "+SIGNAL_FLOOR" child clamps right back to 1.0 and shows nothing.
# Exercise the threshold mechanics against a synthetic below-ceiling
# parent (every 5F sub knocked to 0.90).
identity = fork_score(parent, parent)
knock = 0.90
degraded = _degraded_parent(parent, knock)
# Child recovers EVERY 5F sub uniformly by 0.06 (> SIGNAL_FLOOR each)
# → mean Δ5F = 0.06 → score = γ·0.06 = 0.06 ≥ SIGNAL_FLOOR → ACCEPT.
child_uniform = _deepcopy_bundle(degraded)
for sub in _BATTERY_RATE_KEYS["5f"]:
_shift_rate(child_uniform, "5f", sub, 0.06)
accept_uniform = fork_score(degraded, child_uniform)
# Child recovers only ONE 5F sub by 0.06 → mean Δ5F = 0.06/5 = 0.012
# → score 0.012 < SIGNAL_FLOOR → MARGINAL. This is the 5× averaging
# dilution: a single-sub gain is worth a fifth of its face value.
child_single = _deepcopy_bundle(degraded)
_shift_rate(child_single, "5f", "function", 0.06)
marginal_single = fork_score(degraded, child_single)
# Child drops one 5F sub by HARD_REGRESSION_FLOOR from the degraded
# parent → hard regression → REJECT regardless of the other terms.
child_regress = _deepcopy_bundle(degraded)
_shift_rate(child_regress, "5f", "falsification", -HARD_REGRESSION_FLOOR)
reject_regress = fork_score(degraded, child_regress)
# ---- emit ----------------------------------------------------------------
out: list[str] = []
out.append("# 5S/5T/5F → v8 ForkScore threshold-calibration handoff")
out.append("")
out.append(f"**Date:** {now}")
out.append("**Ticket:** #000025 §10.14 (closure deliverable) — handoff to #000012.")
out.append(
"**Method:** ran the canonical 5S/5T/5F sub-batteries (the packs "
"`fork_score._BATTERY_RATE_KEYS` reads) plus the 5F live packs; "
"computed baseline rates, observability granularity (1/n), and "
"ran `fork_score` on the parent vs three synthetic child "
"perturbations. Pure measurement."
)
out.append("")
out.append("## 1. Baseline rates + granularity")
out.append("")
out.append(
"Each row's *rate* is the single metric `fork_score` consumes for "
"that sub-battery (`_BATTERY_RATE_KEYS`). *Granularity* = 1/n — "
"the smallest rate change a single fixture flip can produce, i.e. "
"the finest Δ the scorer could ever observe on that pack."
)
out.append("")
out.append("| battery | sub-battery | fixtures (n) | embedded rate | live rate | granularity (1/n) |")
out.append("|---|---|---|---|---|---|")
for battery, sub, n, rate, gran, live_rate in rows:
rate_s = f"{rate:.4f}" if rate is not None else ""
live_s = f"{live_rate:.4f}" if live_rate is not None else ""
out.append(f"| {battery} | {sub} | {n} | {rate_s} | {live_s} | {gran:.4f} ({gran*100:.1f}pp) |")
out.append("")
# Per-battery mean rate.
for battery in ("5s", "5t", "5f"):
vals = [rate for b, s, n, rate, g, lr in rows if b == battery and rate is not None]
if vals:
out.append(f"- **{battery.upper()} mean baseline rate:** {statistics.fmean(vals):.4f}")
out.append(f"- **Coarsest pack granularity:** {coarsest:.4f} ({coarsest*100:.1f}pp)")
out.append(f"- **Finest pack granularity:** {finest:.4f} ({finest*100:.1f}pp)")
out.append("")
out.append("## 2. Identity-fork verdict (no change)")
out.append("")
out.append(f"`fork_score(parent, parent)` → **{_fmt_verdict(identity)}**")
out.append("")
out.append(
"Every 5S/5T/5F pack is at ceiling (rate 1.0) at HEAD, so every "
"Δ-rate term is exactly 0 and the identity fork lands in the "
"`[0, SIGNAL_FLOOR)` band → **MARGINAL**, not ACCEPT. Takeaway for "
"#000012: an unchanged child is *not* auto-accepted; positive "
"score has to come from a Δ-rate gain (needs harder fixtures, see "
"§4), an efficiency-bonus increase (`adaptation_efficiency` / "
"`feedback_efficiency`), or one of the non-bench terms "
"(`selfmodel_calibration_gain`, `audit_completeness`, …)."
)
out.append("")
out.append("## 3. Floor-constant sanity checks")
out.append("")
out.append(
f"`SIGNAL_FLOOR = {SIGNAL_FLOOR}` · `HARD_REGRESSION_FLOOR = "
f"{HARD_REGRESSION_FLOOR}` (current `arborist/substrate/fork_score.py`). "
f"The production parent is at ceiling, so these are exercised against "
f"a synthetic *degraded parent* — every 5F sub-battery rate knocked "
f"to {knock:.2f}, giving a child headroom to improve."
)
out.append("")
out.append("| scenario | verdict |")
out.append("|---|---|")
out.append(f"| identity — `fork_score(HEAD, HEAD)` | {_fmt_verdict(identity)} |")
out.append(f"| degraded parent → child recovers **all 5** 5F subs by +0.06 | {_fmt_verdict(accept_uniform)} |")
out.append(f"| degraded parent → child recovers **only 1** 5F sub by +0.06 | {_fmt_verdict(marginal_single)} |")
out.append(f"| degraded parent → child drops one 5F sub by {HARD_REGRESSION_FLOOR} | {_fmt_verdict(reject_regress)} |")
out.append("")
out.append(
"Row 2 vs row 3 is the **5× averaging dilution**: `_delta_5f` means "
"over all 5 sub-batteries, so the same +0.06 gain scores `1.0·0.06 = "
"0.06` (ACCEPT) when applied to all five subs but only `1.0·(0.06/5) "
"= 0.012` (MARGINAL) when applied to one. Each battery contributes a "
"separate Δ-term at weight 1.0, so a uniform +`SIGNAL_FLOOR` across "
"*every* sub of *all three* batteries would score `~0.15`."
)
out.append("")
out.append("## 4. Recommendation for #000012")
out.append("")
out.append(
f"- **Keep `SIGNAL_FLOOR = {SIGNAL_FLOOR}` and "
f"`HARD_REGRESSION_FLOOR = {HARD_REGRESSION_FLOOR}`.** They match "
"`docs/bench-maxing.md`'s 5-pp signal floor and the sanity checks "
"in §3 behave as documented."
)
out.append(
f"- **Granularity caveat (load-bearing):** the foundational 5S "
f"packs are *coarser* than the floors — `syntax` (n=10 → 10.0pp) "
f"and `semantics` (n=8 → 12.5pp). On those packs a single fixture "
f"flip is ≥ {HARD_REGRESSION_FLOOR*100:.0f}pp, so *any* regression "
f"there trips the hard-reject. That is the intended zero-tolerance "
f"behaviour on the syntax/semantics base — not a bug — but #000012 "
f"should document it: HARD_REGRESSION_FLOOR is not a tunable knob "
f"on the small packs, it's effectively 'one fixture'. The n=30 / "
f"n=50 / n=62 packs have 3.3pp / 2.0pp / 1.6pp granularity, so "
f"there {HARD_REGRESSION_FLOOR*100:.0f}pp ≈ 1.53 fixture flips."
)
out.append(
"- **5× averaging dilution (document this in #000012):** a single "
"sub-battery's rate gain is worth a fifth of its face value because "
"`_delta_5{s,t,f}` means over 5 subs. So 'a fork must improve "
f"by `SIGNAL_FLOOR`' really means *one of*: ~`{SIGNAL_FLOOR*5:.2f}` "
f"on a single sub, ~`{SIGNAL_FLOOR:.2f}` uniform across one "
f"battery's 5 subs, or ~`{SIGNAL_FLOOR/3:.3f}` uniform across the "
"whole 15-sub suite. If #000012 wants single-sub improvements to "
"weigh equally it should switch `_delta_*` from mean to "
"max-or-sum — but that's a #000012-owned design call, not a "
"calibration finding."
)
out.append(
"- **Ceiling saturation:** until harder fixtures drop a pack's "
"baseline below 1.0, the `α·Δ5s + β·Δ5t + γ·Δ5f` terms can only be "
"≤ 0. ForkScore acceptance at the current pack difficulty is "
"driven by the efficiency bonuses + non-bench terms. If #000012 "
"wants the bench Δ-rate terms to carry real positive signal, the "
"5S/5T/5F packs need a harder tier (or a deliberately-degraded "
"parent baseline) — track that as a #000025 follow-up, not a "
"#000012 blocker."
)
out.append(
"- **No constant change shipped by this calibration.** This is a "
"handoff document; if #000012 decides to move a floor it owns that "
"edit (and the resulting `governance_policy_hash` is unaffected — "
"ForkScore constants don't fold into it; they're estimator "
"parameters pinned by `ESTIMATOR_VERSION`)."
)
out.append("")
# ---- §5. aggregator comparison on the #000046 below-ceiling pack --------
out.append("## 5. `delta_aggregator` comparison (#000047, on the #000046 below-ceiling pack)")
out.append("")
hard_pack = _FIX / "5f" / "falsification-hard-v1.jsonl"
if hard_pack.exists():
hr = b_5f.run_falsification(hard_pack).metrics["error_detection_rate"]
# parent = hard-pack rate on 5f/falsification; child = a verifier
# tightening lifting it to 1.0 (a single-sub gain of (1 - hr)).
p_hard = {"5s": {}, "5t": {}, "5f": {"falsification": {"error_detection_rate": hr}}}
c_hard = {"5s": {}, "5t": {}, "5f": {"falsification": {"error_detection_rate": 1.0}}}
out.append(
f"Below-ceiling baseline: `5f/falsification` at the "
f"`bench/fixtures/5f/falsification-hard-v1.jsonl` rate "
f"**{hr:.4f}** ({hr*12:.0f}/12 — `verify_quotes` over-grounds "
f"the rest); a child fork that tightens `verify_quotes` lifts "
f"it toward 1.0 — a *single-sub* gain of {1.0-hr:.4f}. How that "
f"single-sub gain scores under each aggregator:"
)
out.append("")
out.append("| aggregator | `γ·Δ5f` | verdict | note |")
out.append("|---|---|---|---|")
notes = {
"mean": "single-sub gain diluted 1/5 (still clears the floor here — the gain is large; a smaller fix would land MARGINAL, see §3)",
"max": "single-sub gain at face value",
"sum": "= max here (one sub); diverges from max only on a broad multi-sub gain",
}
for agg in DELTA_AGGREGATORS:
sf = fork_score(p_hard, c_hard, weights=WeightSet(delta_aggregator=agg))
out.append(
f"| `{agg}` | {sf.breakdown['gamma_x_delta_5f']:+.4f} | "
f"{sf.verdict} | {notes[agg]} |"
)
out.append("")
out.append(
"Reading: `mean` (default) makes a single-sub verifier fix worth "
"≈ a fifth of its face value — so closing #000046 (lifting one "
"5F sub) is rewarded modestly, while a broad cross-sub "
"improvement is rewarded fully; `max`/`sum` flip that. The "
"default stays `mean` (the conservative, noise-robust, "
"regression-symmetric choice — `docs/bench-maxing.md`'s per-rate "
"floor framing); #000012 can pick `max`/`sum` per-deployment via "
"`WeightSet(delta_aggregator=...)` (recorded in "
"`ScoredFork.weights`). #000047 ships the knob, not a default "
"change."
)
else:
out.append("_(hard pack not present — skipping; see #000046.)_")
out.append("")
return "\n".join(out)
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
p.add_argument(
"--out",
type=Path,
default=None,
help="Write the markdown report here (default: stdout).",
)
args = p.parse_args(argv)
md = build_report()
if args.out:
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(md, encoding="utf-8")
print(f"wrote {args.out}")
else:
print(md)
return 0
if __name__ == "__main__":
sys.exit(main())