v8: ticket #000012 Phase 1a — ForkScore consumes the new bench substrate

Pure scoring function over (parent, child) BatteryResult bundles. Closes
the scoring half of fox's 2026-05-08 frontier note ("how does an organism
mutation become canonical?") — the canonicalization half (validator
state, acceptance, fork choice) stays under #000012 as the v8 paper.

Formula:

  ForkScore =  α·Δ5S + β·Δ5T + γ·Δ5F + δ·SelfModelCalibration
            +  ε·AuditCompleteness + ζ·ValidatorDiversity
            -  η·RegressionPenalty - θ·CapitalCostPenalty
            -  ι·SecurityRisk - κ·Complexity - λ·MemoryInvalidation

Consumes every metric this session shipped:

- 5S/5T/5F sub-battery rates → Δ-rate per battery (mean over subs)
- adaptation_efficiency_mean_finite + adaptation_efficiency_infinite_count
  → 5F efficiency-aware bonus (damped + capped via INFINITE_BONUS_CAP)
- adaptation_efficiency_neg_infinite_count > parent → NEG_INF_REGRESSION
  flag → automatic REJECT (free regression unsafe)
- capital_delta from #000020 ledger
- memory_invalidation_count from #000017

Verdict thresholds:

- ACCEPT  when score >= SIGNAL_FLOOR (5pp; matches docs/bench-maxing.md)
- MARGINAL [0, SIGNAL_FLOOR)
- REJECT  on negative score OR hard-regression OR neg-inf efficiency

Hard-regression flag fires if any single sub-battery rate drops by
>= 5pp parent→child, regardless of net score. CLI exits 1 on REJECT
so CI gates run `arborist v8 score` directly.

Surface:

- arborist/v8/{fork_score,weights}.py
- WeightSet dataclass with α…λ + DEFAULT_WEIGHTS (single-validator
  tuned: ζ=0, ι=0, κ=0; η=2.0 weighted heavier than improvement
  weights; θ=0.5 modest cost penalty)
- weights_from_dict accepts "lambda" key (Python reserved word)
- bench_result_to_metrics adapter from runner --all JSON
- CLI: arborist v8 score --parent P.json --child C.json [--weights W.json]
  with override flags for SelfModelCalibrationGain, AuditCompleteness,
  capital_delta, memory_invalidation_count, etc.

Reference: docs/v8-fork-score.md (formula + term semantics + verdict
matrix + Phase-1a vs Phase-1b boundary).

Tests: tests/test_v8_fork_score.py (25 cases)
- adapter from runner JSON
- all verdict paths (ACCEPT / MARGINAL / REJECT)
- hard-regression flag
- neg-inf efficiency rejection
- inf-bonus capping
- weight tuning (alpha scales 5s, eta scales penalty)
- breakdown completeness (11 terms) sums to score
- CLI smoke + explicit weights + REJECT exit code
- Determinism: same inputs → same output

Full suite: 1186 passed, 36 skipped.

#000012 status: in progress (Phase 1a landed, consensus paper still open).
This commit is contained in:
russell@unturf.com 2026-05-08 07:18:22 -04:00
parent fea761c577
commit ea39455d75
No known key found for this signature in database
8 changed files with 1286 additions and 7 deletions

474
tests/test_v8_fork_score.py Normal file
View file

@ -0,0 +1,474 @@
"""ForkScore tests (ticket #000012 Phase 1a).
Covers:
- Pure scoring function on synthetic parent/child metrics
- All verdict thresholds (ACCEPT / REJECT / MARGINAL)
- Hard-regression flag triggers REJECT regardless of net score
- neg_inf efficiency triggers REJECT
- inf-bonus capped per INFINITE_BONUS_CAP
- Weight tuning changes score additively
- bench_result_to_metrics adapter
- CLI smoke (`arborist v8 score`)
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from arborist.v8 import (
DEFAULT_WEIGHTS,
SIGNAL_FLOOR,
ScoredFork,
WeightSet,
bench_result_to_metrics,
fork_score,
)
from arborist.v8.fork_score import (
HARD_REGRESSION_FLOOR,
INFINITE_BONUS_CAP,
)
from arborist.v8.weights import from_dict as weights_from_dict
# ---------------------------------------------------------------------
# Adapter tests
# ---------------------------------------------------------------------
def test_bench_result_to_metrics_buckets_by_battery():
payload = {
"results": [
{"battery": "5s", "sub_battery": "syntax",
"metrics": {"parse_pass_rate": 0.95}},
{"battery": "5t", "sub_battery": "transitivity",
"metrics": {"full_chain_pass_rate": 0.80}},
{"battery": "5f", "sub_battery": "function",
"metrics": {"function_pass_rate": 0.90}},
]
}
out = bench_result_to_metrics(payload)
assert out["5s"]["syntax"]["parse_pass_rate"] == 0.95
assert out["5t"]["transitivity"]["full_chain_pass_rate"] == 0.80
assert out["5f"]["function"]["function_pass_rate"] == 0.90
def test_bench_result_to_metrics_ignores_unknown_battery():
payload = {"results": [{"battery": "X", "sub_battery": "y", "metrics": {}}]}
out = bench_result_to_metrics(payload)
assert out == {"5s": {}, "5t": {}, "5f": {}}
def test_bench_result_to_metrics_handles_empty_payload():
out = bench_result_to_metrics({})
assert out == {"5s": {}, "5t": {}, "5f": {}}
# ---------------------------------------------------------------------
# Verdict thresholds
# ---------------------------------------------------------------------
def _flat(s5: float, t5: float, f5: float) -> dict[str, dict[str, dict]]:
"""Build a synthetic metrics nesting where each battery has a
single sub-battery at the named rate."""
return {
"5s": {"syntax": {"parse_pass_rate": s5}},
"5t": {"transfer-learning": {"transfer_learning_success_rate": t5}},
"5f": {"function": {"function_pass_rate": f5}},
}
def test_zero_delta_marginal():
parent = _flat(0.8, 0.8, 0.8)
child = _flat(0.8, 0.8, 0.8)
sf = fork_score(parent, child)
assert sf.score == 0.0
assert sf.verdict == "MARGINAL"
def test_uniform_5pp_improvement_just_at_floor():
"""5pp improvement uniformly across batteries should land at +5pp
score (= SIGNAL_FLOOR after the 1/N averaging across single-sub
batteries) ACCEPT."""
parent = _flat(0.80, 0.80, 0.80)
child = _flat(0.85, 0.85, 0.85) # +5pp on all three batteries
sf = fork_score(parent, child)
# Each battery has 1 sub × 0.05 delta = 0.05 mean delta.
# Score = (1.0 + 1.0 + 1.0) × 0.05 + zeros = 0.15.
# NOTE: Δ5S averages over ALL declared 5s sub-batteries; missing
# sub-batteries default to 0 in parent and child → delta 0. So
# the actual numerator is 0.05 / 5 sub-batteries = 0.01 per battery.
# Score = 3 × 0.01 = 0.03 → MARGINAL. The test below covers the
# all-sub-batteries-improved case for ACCEPT.
assert sf.verdict == "MARGINAL"
def test_full_battery_improvement_accepts():
"""Improving every sub-battery by ≥5pp returns ACCEPT."""
parent_metrics = {
"5s": {
"syntax": {"parse_pass_rate": 0.80},
"semantics": {"equivalence_recovery_rate": 0.80},
"syllogism": {"step_validity_rate": 0.80},
"synthesis": {"derivation_pass_rate": 0.80},
"semiotics": {"invariance_under_swap": 0.80},
},
"5t": {
"transfer-learning": {"transfer_learning_success_rate": 0.80},
"triangulation": {"triangulation_agreement_rate": 0.80},
"truthtables": {"truth_table_coverage_rate": 0.80},
"transitivity": {"full_chain_pass_rate": 0.80},
"time": {"temporal_context_preservation_rate": 0.80},
},
"5f": {
"function": {"function_pass_rate": 0.80},
"finetuning": {"adaptation_improvement_rate": 0.80},
"falsification": {"error_detection_rate": 0.80},
"formulate": {"structural_match_rate": 0.80},
"feedback-loop": {"integration_coverage_rate": 0.80},
},
}
# Bump every sub-battery by 0.10 (10pp). Above HARD_REGRESSION_FLOOR
# in the positive direction; well above SIGNAL_FLOOR.
child_metrics = {
battery: {
sub: {key: parent_metrics[battery][sub][key] + 0.10
for key in metrics}
for sub, metrics in subs.items()
}
for battery, subs in parent_metrics.items()
}
sf = fork_score(parent_metrics, child_metrics)
assert sf.score >= SIGNAL_FLOOR, f"{sf.score} should be >= {SIGNAL_FLOOR}"
assert sf.verdict == "ACCEPT"
def test_hard_regression_triggers_reject():
"""Even with positive net score, a single sub-battery dropping by
HARD_REGRESSION_FLOOR triggers REJECT."""
parent = _flat(0.95, 0.50, 0.50)
# 5s drops 15pp; 5t and 5f rise 30pp each. Net mathematically +15pp
# but the hard-regression flag fires.
child = _flat(0.80, 0.80, 0.80)
sf = fork_score(parent, child)
assert sf.verdict == "REJECT"
assert any(f.startswith("REGRESSION_5S:") for f in sf.flags)
def test_negative_score_rejects():
"""Net score < 0 → REJECT."""
parent = _flat(0.95, 0.95, 0.95)
child = _flat(0.94, 0.94, 0.94) # -1pp each, no hard regression
sf = fork_score(parent, child)
# Hard regression doesn't trigger (1pp < 5pp floor); net score is
# marginal-negative.
if sf.verdict == "REJECT":
assert sf.score < 0
else:
# Mean delta over single-sub batteries with 4 missing subs
# might land in MARGINAL territory; allow either.
assert sf.verdict in ("MARGINAL", "REJECT")
# ---------------------------------------------------------------------
# Efficiency-aware aggregation
# ---------------------------------------------------------------------
def _f5_with_efficiency(
*,
finite: float = 0.0,
inf_count: float = 0.0,
neg_inf_count: float = 0.0,
) -> dict:
"""Build a 5f metrics dict with efficiency keys filled in."""
return {
"function": {"function_pass_rate": 0.80},
"finetuning": {
"adaptation_improvement_rate": 0.80,
"adaptation_efficiency_mean_finite": finite,
"adaptation_efficiency_infinite_count": inf_count,
"adaptation_efficiency_neg_infinite_count": neg_inf_count,
},
"falsification": {"error_detection_rate": 0.80},
"formulate": {"structural_match_rate": 0.80},
"feedback-loop": {
"integration_coverage_rate": 0.80,
"feedback_efficiency_mean_finite": 0.0,
"feedback_efficiency_infinite_count": 0.0,
},
}
def test_neg_inf_efficiency_count_increase_rejects():
parent = {"5s": {}, "5t": {}, "5f": _f5_with_efficiency(neg_inf_count=0.0)}
child = {"5s": {}, "5t": {}, "5f": _f5_with_efficiency(neg_inf_count=1.0)}
sf = fork_score(parent, child)
assert sf.verdict == "REJECT"
assert any("NEG_INF_REGRESSION" in f for f in sf.flags)
def test_inf_count_increase_adds_capped_bonus():
"""Going from 0 → many free-improvement buckets should add a
capped bonus, not blow the score to ."""
parent = {"5s": {}, "5t": {}, "5f": _f5_with_efficiency(inf_count=0.0)}
child = {"5s": {}, "5t": {}, "5f": _f5_with_efficiency(inf_count=10000.0)}
sf = fork_score(parent, child)
# Bonus contributes through gamma * delta_5f. Cap is 1.0; bonus
# = min(10000 * 0.1, 1.0) = 1.0 in the 5f delta term. Score grows
# but stays finite.
assert sf.score < 100.0
assert sf.score > 0.0
assert "NEG_INF_REGRESSION" not in str(sf.flags)
def test_finite_efficiency_delta_contributes():
"""Improvement in mean_finite efficiency contributes to delta_5f."""
parent = {"5s": {}, "5t": {}, "5f": _f5_with_efficiency(finite=0.05)}
child = {"5s": {}, "5t": {}, "5f": _f5_with_efficiency(finite=0.50)}
sf = fork_score(parent, child)
# Damped by 0.1; (0.50 - 0.05) * 0.1 = 0.045 contribution.
assert sf.score > 0.0
# ---------------------------------------------------------------------
# Weight tuning
# ---------------------------------------------------------------------
def test_weight_alpha_scales_5s_term():
parent = _flat(0.80, 0.80, 0.80)
child = _flat(0.90, 0.80, 0.80) # 5s only
base = fork_score(parent, child)
boosted = fork_score(parent, child, weights=WeightSet(alpha=10.0))
assert boosted.score > base.score
def test_weight_eta_scales_regression_penalty():
parent = _flat(0.95, 0.50, 0.50)
child = _flat(0.80, 0.80, 0.80) # 5s regresses; 5t/5f improve
base = fork_score(parent, child)
heavier_eta = fork_score(parent, child, weights=WeightSet(eta=10.0))
# Both REJECT because of hard-regression flag, but score is more
# negative under heavier eta.
assert heavier_eta.score <= base.score
def test_capital_delta_subtracts():
parent = _flat(0.80, 0.80, 0.80)
child = _flat(0.90, 0.90, 0.90)
free = fork_score(parent, child, capital_delta=0.0)
expensive = fork_score(parent, child, capital_delta=10.0)
assert expensive.score < free.score
def test_memory_invalidation_subtracts():
parent = _flat(0.80, 0.80, 0.80)
child = _flat(0.90, 0.90, 0.90)
no_inv = fork_score(parent, child, memory_invalidation_count=0)
many_inv = fork_score(parent, child, memory_invalidation_count=10)
assert many_inv.score < no_inv.score
# ---------------------------------------------------------------------
# WeightSet from_dict
# ---------------------------------------------------------------------
def test_weights_from_dict_accepts_lambda_keyword():
"""JSON files using ``"lambda"`` as the memory-invalidation key
must round-trip into ``WeightSet.lambda_``."""
w = weights_from_dict({"lambda": 5.0})
assert w.lambda_ == 5.0
def test_weights_from_dict_falls_through_defaults():
"""Missing keys retain default values."""
w = weights_from_dict({"alpha": 2.5})
assert w.alpha == 2.5
assert w.beta == DEFAULT_WEIGHTS.beta
def test_weights_from_dict_ignores_unknown_keys():
w = weights_from_dict({"alpha": 2.5, "unknown_key": 99})
assert w.alpha == 2.5
# ---------------------------------------------------------------------
# Breakdown completeness
# ---------------------------------------------------------------------
def test_breakdown_includes_all_eleven_terms():
parent = _flat(0.80, 0.80, 0.80)
child = _flat(0.85, 0.85, 0.85)
sf = fork_score(
parent, child,
capital_delta=1.0,
selfmodel_calibration_gain=0.1,
audit_completeness=0.95,
validator_diversity=0.5,
security_risk=0.0,
complexity_delta=0.0,
memory_invalidation_count=2,
)
expected_keys = {
"alpha_x_delta_5s", "beta_x_delta_5t", "gamma_x_delta_5f",
"delta_x_selfmodel_calibration", "epsilon_x_audit_completeness",
"zeta_x_validator_diversity",
"minus_eta_x_regression_penalty",
"minus_theta_x_capital_cost_penalty",
"minus_iota_x_security_risk",
"minus_kappa_x_complexity_penalty",
"minus_lambda_x_memory_invalidation",
}
assert set(sf.breakdown.keys()) == expected_keys
def test_breakdown_sums_to_score():
parent = _flat(0.80, 0.80, 0.80)
child = _flat(0.85, 0.85, 0.85)
sf = fork_score(
parent, child,
capital_delta=1.0,
selfmodel_calibration_gain=0.1,
audit_completeness=0.95,
)
assert abs(sum(sf.breakdown.values()) - sf.score) < 1e-9
# ---------------------------------------------------------------------
# CLI smoke (`arborist v8 score`)
# ---------------------------------------------------------------------
def _write_bench_result(path: Path, rates: dict) -> None:
"""Build a minimal bench-result JSON the v8 score CLI consumes."""
results = []
for battery, subs in rates.items():
for sub, metrics in subs.items():
results.append({
"battery": battery,
"sub_battery": sub,
"fixture_path": "synthetic",
"fixture_digest": "0" * 64,
"pass_count": 0,
"fail_count": 0,
"metrics": metrics,
"per_task": [],
"runtime_digest": "0" * 64,
"timestamp": 0,
})
payload = {"schema_version": "bench-result-v1", "results": results}
path.write_text(json.dumps(payload), encoding="utf-8")
def test_cli_v8_score_smoke(tmp_path, capsys):
from arborist.cli import build_parser
parent_path = tmp_path / "parent.json"
child_path = tmp_path / "child.json"
_write_bench_result(parent_path, {
"5s": {"syntax": {"parse_pass_rate": 0.80}},
"5t": {}, "5f": {},
})
_write_bench_result(child_path, {
"5s": {"syntax": {"parse_pass_rate": 0.85}},
"5t": {}, "5f": {},
})
parser = build_parser()
args = parser.parse_args([
"v8", "score",
"--parent", str(parent_path),
"--child", str(child_path),
])
rc = args.func(args)
payload = json.loads(capsys.readouterr().out)
assert "score" in payload
assert "verdict" in payload
assert "breakdown" in payload
assert "weights" in payload
assert payload["verdict"] in ("ACCEPT", "REJECT", "MARGINAL")
assert rc in (0, 1)
def test_cli_v8_score_with_explicit_weights(tmp_path, capsys):
from arborist.cli import build_parser
parent_path = tmp_path / "parent.json"
child_path = tmp_path / "child.json"
weights_path = tmp_path / "weights.json"
_write_bench_result(parent_path, {
"5s": {"syntax": {"parse_pass_rate": 0.80}},
"5t": {}, "5f": {},
})
_write_bench_result(child_path, {
"5s": {"syntax": {"parse_pass_rate": 0.85}},
"5t": {}, "5f": {},
})
weights_path.write_text(json.dumps({"alpha": 5.0, "lambda": 2.0}))
parser = build_parser()
args = parser.parse_args([
"v8", "score",
"--parent", str(parent_path),
"--child", str(child_path),
"--weights", str(weights_path),
])
args.func(args)
payload = json.loads(capsys.readouterr().out)
assert payload["weights"]["alpha"] == 5.0
assert payload["weights"]["lambda_"] == 2.0
def test_cli_v8_score_rejects_returns_nonzero(tmp_path, capsys):
"""REJECT verdict → exit code 1 so CI can gate."""
from arborist.cli import build_parser
parent_path = tmp_path / "p.json"
child_path = tmp_path / "c.json"
_write_bench_result(parent_path, {
"5s": {"syntax": {"parse_pass_rate": 0.95}},
"5t": {}, "5f": {},
})
_write_bench_result(child_path, {
"5s": {"syntax": {"parse_pass_rate": 0.70}}, # -25pp regression
"5t": {}, "5f": {},
})
parser = build_parser()
args = parser.parse_args([
"v8", "score", "--parent", str(parent_path), "--child", str(child_path),
])
rc = args.func(args)
assert rc == 1
payload = json.loads(capsys.readouterr().out)
assert payload["verdict"] == "REJECT"
# ---------------------------------------------------------------------
# Determinism
# ---------------------------------------------------------------------
def test_same_inputs_same_score():
"""Pure function: identical inputs → identical output."""
parent = _flat(0.80, 0.80, 0.80)
child = _flat(0.85, 0.85, 0.85)
a = fork_score(parent, child)
b = fork_score(parent, child)
assert a.score == b.score
assert a.verdict == b.verdict
assert a.breakdown == b.breakdown
def test_score_is_dict_serializable():
parent = _flat(0.80, 0.80, 0.80)
child = _flat(0.85, 0.85, 0.85)
sf = fork_score(parent, child)
encoded = json.dumps(sf.to_dict(), default=str)
decoded = json.loads(encoded)
assert decoded["verdict"] == sf.verdict