arborist/bench/scripts/selfmodel_chain_snapshot.py
russell@unturf.com d78dccc8ed
#000025 §10.11 + §10.13 + §10.14 — close the 5F battery
Closes the three open Phase-1b items of #000025; every §10 closure
criterion is now met, so the ticket flips to closed.

§10.14 — ForkScore threshold-calibration handoff to #000012.
bench/scripts/fivef_threshold_calibration.py (make bench-5f-threshold-
calibration) runs the canonical 5S/5T/5F packs + the 5F live packs and
reports baseline rates, observability granularity (1/n), and fork_score
verdicts on the parent vs synthetic child perturbations →
bench/results/5f-threshold-calibration-2026-05-11.md. Findings written
into ticket-000012 §8: keep SIGNAL_FLOOR / HARD_REGRESSION_FLOOR at
0.05; the small 5S packs (syntax n=10, semantics n=8) are coarser than
the floors so any regression there trips hard-reject (intended zero-
tolerance); the 5x averaging dilution in _delta_*; ceiling saturation
(every pack at 1.0 -> delta-rate terms <= 0). No constant change
shipped. 6 tests in tests/test_fivef_threshold_calibration.py.

§10.13 — feedback latency / efficiency on real workload.
run_feedback_loop now computes feedback_latency (listed in §5.5 since
Phase 1a, never implemented) — wall-clock seconds to apply a live
chain against its temp shard, surfaced per-task
(feedback_latency_seconds) + battery (feedback_latency_mean_seconds,
feedback_live_task_count). For live chains feedback_efficiency's cost
denominator switched from len(chain) (count of requested ops) to the
persisted footprint _persisted_cost = audit-event rows the chain
actually wrote + their body bytes / 1e6. Embedded chains keep
len(chain) and report feedback_latency_seconds = None. Latency is a
wall-clock field (run-to-run variable, like BatteryResult.timestamp)
and is not a fork_score input. 3 tests in tests/test_bench_batteries.py.

§10.11 — real selfmodel finetuning chains.
bench/scripts/selfmodel_chain_snapshot.py (make bench-5f-selfmodel-
snapshot) appends one chained SelfModel snapshot per run to a
persistent shard (~/.arborist/shards/selfmodel-chain.db, override via
ARBORIST_SELFMODEL_CHAIN_DB) with one CapabilityClaim per sub-battery
(metric = "5S-syntax" etc., measured_value = that pack's rate,
eval_digest = the pack's fixture digest, threshold = SIGNAL_FLOOR).
snapshot() auto-parents, so each snapshot is a distinct root and the
lineage grows by one per run. run_finetuning gains a third dispatch
mode — shard-chain (gated on a task's selfmodel_shard key) — via
_chain_finetuning_measure: reads the two most-recent snapshots
(latest() = child, its parent_selfmodel_root = parent) and measures
improvement on target_capability between them. This is the real
lineage replacing Phase-1a's synthetic parent->child pairs; the
chained delta reflects genuine cross-run drift (0.0 today — the
embedded packs are at ceiling). Operator pack
bench/fixtures/5f/finetuning-shardchain-v1.jsonl (6 tasks) + make
bench-5f-finetuning-shardchain; not in `make bench-5f`, `make test`,
or a fresh checkout (a missing/too-short chain fails honestly). The
real chain shard was bootstrapped 2-deep on 2026-05-11; make
chain-check-shards reports 0 breaks on it (and all other shards).
10 tests in tests/test_selfmodel_chain.py.

Full suite: 2311 passed, 28 skipped.
2026-05-11 07:41:37 -04:00

171 lines
6.8 KiB
Python

"""Append one SelfModel snapshot to a persistent chain shard, with
capability claims drawn from a fresh 5S/5T/5F bench run (#000025 §10.11).
Why this exists: Phase 1a's Finetuning sub-battery scored *synthetic*
parent→child SelfModel pairs; Phase 1b.2's "live" path round-trips a
parent + child through a *fresh temp* shard each run. §10.11 wants the
real thing — a SelfModel lineage that *persists across runs*, so the
Finetuning runner can measure improvement between two genuine,
chained snapshots instead of a pair fabricated for the occasion.
Each invocation:
1. Runs the canonical 5S/5T/5F sub-batteries (embedded packs — the
ones `fork_score._BATTERY_RATE_KEYS` reads). Deterministic, ~1s,
no LLM call.
2. Builds one :class:`arborist.selfmodel.CapabilityClaim` per
sub-battery: ``metric = "5S-syntax"`` etc., ``measured_value`` =
that pack's rate, ``eval_digest`` = that pack's fixture digest,
``threshold`` = ``fork_score.SIGNAL_FLOOR`` (the rate floor below
which a fork-score Δ on this capability is a hard regression).
3. Calls :func:`arborist.selfmodel.snapshot` (which auto-parents to
the latest root in the shard) → :func:`with_claims` →
:func:`store_snapshot`. The new snapshot's distinct parent makes
it a distinct root, so the chain grows by exactly one per run.
Output shard defaults to ``~/.arborist/shards/selfmodel-chain.db``
(override with ``--shard`` or ``ARBORIST_SELFMODEL_CHAIN_DB``). The
shard carries an ``audit_events`` table (every ``store_snapshot``
write chains through it), so ``make chain-check-shards`` covers it.
Idempotent on content: re-running with no chain growth in between is
a no-op only if the parent root is unchanged — which it isn't after
the first append, so successive runs always extend the lineage. The
bench rates are at ceiling (1.0) today, so the chained Δ is 0.0; the
point is that the *mechanism* is real and the lineage outlives any
single process.
"""
from __future__ import annotations
import argparse
import os
import sys
import time
from dataclasses import asdict
from pathlib import Path
_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 arborist.selfmodel import CapabilityClaim, snapshot, store_snapshot # noqa: E402
from arborist.selfmodel.canonical import with_claims # noqa: E402
from arborist.selfmodel.store import claims_for, latest # noqa: E402
from arborist.store import connect, transaction # noqa: E402
from arborist.substrate.fork_score import SIGNAL_FLOOR, _BATTERY_RATE_KEYS # noqa: E402
_FIX = _REPO_ROOT / "bench" / "fixtures"
# Canonical sub-battery → embedded fixture pack (the keys fork_score
# reads). Mirrors bench.batteries.runner._DEFAULT_FIXTURES minus the
# legacy 5t "transfer" the scorer ignores.
_PACKS: list[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"),
]
_SUB_RUNNERS = {"5s": b_5s.SUB_BATTERIES, "5t": b_5t.SUB_BATTERIES, "5f": b_5f.SUB_BATTERIES}
def default_shard() -> Path:
env = os.environ.get("ARBORIST_SELFMODEL_CHAIN_DB")
if env:
return Path(env).expanduser()
return Path.home() / ".arborist" / "shards" / "selfmodel-chain.db"
def _battery_claims(measured_at: int) -> list[CapabilityClaim]:
claims: list[CapabilityClaim] = []
for battery, sub, path in _PACKS:
res = _SUB_RUNNERS[battery][sub](path)
metric_key = _BATTERY_RATE_KEYS[battery][sub]
rate = float(res.metrics.get(metric_key, 0.0))
claims.append(
CapabilityClaim(
metric=f"{battery.upper()}-{sub}",
threshold=float(SIGNAL_FLOOR),
eval_digest=res.fixture_digest,
measured_value=rate,
measured_at=measured_at,
validity_horizon="next-checkpoint",
claim_text=f"{battery.upper()} {sub} {metric_key} on the embedded pack",
)
)
return claims
def append_snapshot(shard: Path, *, ts: int | None = None) -> dict:
"""Run the suite, append one chained SelfModel snapshot, return a
summary dict ``{root, parent_root, depth, claims: [...]}``."""
if ts is None:
ts = int(time.time())
shard.parent.mkdir(parents=True, exist_ok=True)
conn = connect(shard)
try:
prev = latest(conn)
claims = _battery_claims(ts)
with transaction(conn):
sm = with_claims(snapshot(conn), claims)
root = store_snapshot(conn, sm, claims=claims, ts=ts)
# Walk the parent chain to report depth.
depth = 1
cur = load_parent(conn, root)
while cur is not None:
depth += 1
cur = load_parent(conn, cur)
rows = claims_for(conn, root)
return {
"shard": str(shard),
"root": root,
"parent_root": prev["selfmodel_root"] if prev else None,
"depth": depth,
"claims": {r["metric"]: r["measured_value"] for r in rows},
}
finally:
conn.close()
def load_parent(conn, root: str) -> str | None:
row = conn.execute(
"SELECT parent_selfmodel_root FROM selfmodel_records WHERE selfmodel_root = ?",
(root,),
).fetchone()
if row is None:
return None
return row["parent_selfmodel_root"]
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
p.add_argument(
"--shard",
type=Path,
default=None,
help="Chain shard path (default: $ARBORIST_SELFMODEL_CHAIN_DB or ~/.arborist/shards/selfmodel-chain.db)",
)
args = p.parse_args(argv)
shard = args.shard.expanduser() if args.shard else default_shard()
summary = append_snapshot(shard)
import json as _json
print(_json.dumps(summary, indent=2, default=str))
return 0
if __name__ == "__main__":
sys.exit(main())