Per §22 Finding 2: flat capital_cost=1.0 made every utility negative in the dry-run sim. Split capital_cost into kernel_cost (~0.05 kernel re-probe) + llm_cost (~1.0 LLM witness fan-out); ControllerBranch now exposes effective_cost = kernel_cost + llm_cost when either is positive, falling back to legacy capital_cost when both are zero. Pre-1.c callers don't migrate. _utility() reads effective_cost so split-cost and legacy-cost branches with the same total cost produce byte-identical utility values. Per §22 Finding 3 (partial): add sweep_weights() profile — gamma_5f=1.5, lambda_capital_cost=0.25, nu_witness_divergence=0.5. Sweep work willingly pays capital for falsification discovery and treats high witness divergence as desirable signal (§13 step 11). Registered in WEIGHT_PROFILES["sweep"]; folds into governance_policy selection alongside safe / conservative / exploratory. Tests: 6 new in tests/test_prometheus.py — effective_cost split path, legacy fallback, _utility byte-identity across the two cost shapes, kernel-only vs LLM-only ranking, sweep-vs-safe divergence on a marginal branch (DEFERRED under safe, ACCEPT under sweep), and WEIGHT_PROFILES registry now pins all four profiles.
955 lines
34 KiB
Python
955 lines
34 KiB
Python
"""Phase 1 tests for arborist.substrate.prometheus (ticket #000037).
|
||
|
||
Covers the §16.2 test list — 17 named cases — plus a small set of
|
||
sanity checks (determinism, weight-profile shape, trigger-check
|
||
positive path) that don't appear in the §16.2 list but harden the
|
||
"pure function" contract.
|
||
|
||
Discipline (§16.1):
|
||
|
||
- Pure-function — no DB, no LLM, no scheduler.
|
||
- Banned imports verified via AST inspection of the module source.
|
||
- Proposals only; no mutation API calls.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import ast
|
||
import math
|
||
import random
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from arborist.substrate.prometheus import (
|
||
ABS_STDDEV_ZERO_MEAN,
|
||
H_HIGH,
|
||
H_LOW,
|
||
KAPPA_MEMORY,
|
||
N_MIN_TRIGGER_2,
|
||
WEIGHT_PROFILES,
|
||
BatteryDeltas,
|
||
ControllerBranch,
|
||
ControllerDecision,
|
||
ControllerInput,
|
||
ControllerWeights,
|
||
MemoryRootUpdateProposal,
|
||
SelfModelUpdateProposal,
|
||
conservative_weights,
|
||
controller_decide,
|
||
exploratory_weights,
|
||
phase_1_trigger_check,
|
||
safe_weights,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# Test helpers
|
||
# ---------------------------------------------------------------------
|
||
|
||
|
||
def _b(
|
||
branch_id: str = "b1",
|
||
*,
|
||
delta_5s: float = 0.05,
|
||
delta_5t: float = 0.05,
|
||
delta_5f: float = 0.05,
|
||
delta_5r: float = 0.0,
|
||
witness_divergence: float = 0.0,
|
||
capital_cost: float = 0.0,
|
||
regression_penalty: float = 0.0,
|
||
security_risk: float = 0.0,
|
||
memory_invalidation: float = 0.0,
|
||
selfmodel_calibration_gain: float = 0.0,
|
||
warrant_promotion_gain: float = 0.0,
|
||
hard_vetoes: tuple[str, ...] = (),
|
||
payoff_b: float = 1.0,
|
||
) -> ControllerBranch:
|
||
return ControllerBranch(
|
||
branch_id=branch_id,
|
||
deltas=BatteryDeltas(delta_5s, delta_5t, delta_5f, delta_5r),
|
||
witness_divergence=witness_divergence,
|
||
capital_cost=capital_cost,
|
||
regression_penalty=regression_penalty,
|
||
security_risk=security_risk,
|
||
memory_invalidation=memory_invalidation,
|
||
selfmodel_calibration_gain=selfmodel_calibration_gain,
|
||
warrant_promotion_gain=warrant_promotion_gain,
|
||
hard_vetoes=hard_vetoes,
|
||
payoff_b=payoff_b,
|
||
)
|
||
|
||
|
||
def _inp(
|
||
branches: tuple[ControllerBranch, ...],
|
||
*,
|
||
organism_root: str = "Ω0",
|
||
budget: int = 4,
|
||
hermes_utilization: int = 0,
|
||
weights: ControllerWeights | None = None,
|
||
difficulty: float = 1.0,
|
||
divergence_rate: float = 0.0,
|
||
hard_regression_rate: float = 0.0,
|
||
witness_agreement_rate: float = 1.0,
|
||
capital_pressure: float = 0.0,
|
||
) -> ControllerInput:
|
||
return ControllerInput(
|
||
organism_root=organism_root,
|
||
branches=branches,
|
||
budget=budget,
|
||
hermes_utilization=hermes_utilization,
|
||
weights=weights or safe_weights(),
|
||
difficulty=difficulty,
|
||
divergence_rate=divergence_rate,
|
||
hard_regression_rate=hard_regression_rate,
|
||
witness_agreement_rate=witness_agreement_rate,
|
||
capital_pressure=capital_pressure,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# §16.2 #1 — test_all_vetoed_returns_reject_or_quarantine
|
||
# ---------------------------------------------------------------------
|
||
|
||
|
||
def test_all_vetoed_returns_reject_or_quarantine():
|
||
"""Every branch vetoed → label is REJECT or QUARANTINE (or
|
||
ESCALATE for the most severe class). selected_branch_id None,
|
||
notes mention ALL_BRANCHES_VETOED."""
|
||
branches = (
|
||
_b("a", hard_vetoes=("hard_regression",)), # REJECT class
|
||
_b("b", hard_vetoes=("verifier_failure",)), # REJECT class
|
||
)
|
||
d = controller_decide(_inp(branches))
|
||
assert d.label == "REJECT"
|
||
assert d.selected_branch_id is None
|
||
assert "ALL_BRANCHES_VETOED" in d.notes
|
||
|
||
# Mixed REJECT + QUARANTINE → QUARANTINE wins (higher severity).
|
||
branches = (
|
||
_b("a", hard_vetoes=("hard_regression",)),
|
||
_b("b", hard_vetoes=("cache_drift",)),
|
||
)
|
||
d = controller_decide(_inp(branches))
|
||
assert d.label == "QUARANTINE"
|
||
|
||
# Any ESCALATE-class veto → ESCALATE wins (highest severity).
|
||
branches = (
|
||
_b("a", hard_vetoes=("hard_regression",)),
|
||
_b("b", hard_vetoes=("memory_invalidation_exceeded",)),
|
||
)
|
||
d = controller_decide(_inp(branches))
|
||
assert d.label == "ESCALATE"
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# §16.2 #2 — test_zero_budget_returns_deferred
|
||
# ---------------------------------------------------------------------
|
||
|
||
|
||
def test_zero_budget_returns_deferred():
|
||
"""B = 0 (Hermes saturated) → DEFERRED for everything, regardless
|
||
of how good the branches look."""
|
||
branches = (_b("a", delta_5s=0.2), _b("b", delta_5s=0.3))
|
||
d = controller_decide(_inp(branches, budget=0))
|
||
assert d.label == "DEFERRED"
|
||
assert d.selected_branch_id is None
|
||
assert all(v == 0.0 for v in d.allocations.values())
|
||
assert "ZERO_BUDGET" in d.notes
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# §16.2 #3 — test_stable_softmax_no_overflow
|
||
# ---------------------------------------------------------------------
|
||
|
||
|
||
def test_stable_softmax_no_overflow():
|
||
"""Utilities ±10000 must not overflow exp() — the stable softmax
|
||
max-shift bounds every scaled-exp in [0, 1]."""
|
||
# Both very large positive and very large negative inputs.
|
||
branches = (
|
||
_b("hot", delta_5s=10000.0, delta_5t=10000.0, delta_5f=10000.0),
|
||
_b("cold", delta_5s=-10000.0, delta_5t=-10000.0, delta_5f=-10000.0),
|
||
_b("middle", delta_5s=0.1),
|
||
)
|
||
d = controller_decide(_inp(branches))
|
||
# No NaN / inf escapes.
|
||
assert math.isfinite(d.entropy)
|
||
for v in d.allocations.values():
|
||
assert math.isfinite(v)
|
||
# Hot branch gets virtually all the budget.
|
||
assert d.allocations["hot"] > d.allocations["cold"]
|
||
assert d.allocations["hot"] > d.allocations["middle"]
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# §16.2 #4 — test_negative_payoff_gets_zero_allocation
|
||
# ---------------------------------------------------------------------
|
||
|
||
|
||
def test_negative_payoff_gets_zero_allocation():
|
||
"""Branch with payoff_b <= 0 — Kelly's "free regression" guard.
|
||
Allocation zero; if it's the only branch the label REJECTs."""
|
||
branches = (_b("a", delta_5s=0.1, payoff_b=-0.5),)
|
||
d = controller_decide(_inp(branches))
|
||
# Σ_raw == 0 → DEFERRED (the spec-defined exit when no positive
|
||
# allocation survives). Both DEFERRED and REJECT are valid
|
||
# "negative-payoff produces no work" outcomes — assert no positive
|
||
# allocation either way.
|
||
assert d.allocations["a"] == 0.0
|
||
assert d.label in {"DEFERRED", "REJECT"}
|
||
|
||
# When another branch has positive payoff, the negative one stays
|
||
# at zero and the positive one selects.
|
||
branches = (
|
||
_b("neg", delta_5s=0.1, payoff_b=-0.5),
|
||
_b("pos", delta_5s=0.1, payoff_b=2.0),
|
||
)
|
||
d = controller_decide(_inp(branches))
|
||
assert d.allocations["neg"] == 0.0
|
||
assert d.allocations["pos"] > 0.0
|
||
assert d.selected_branch_id == "pos"
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# §16.2 #5 — test_unsupported_carrier_quarantines
|
||
# ---------------------------------------------------------------------
|
||
|
||
|
||
def test_unsupported_carrier_quarantines():
|
||
"""`unsupported_carrier` veto on the only branch → QUARANTINE."""
|
||
branches = (_b("a", hard_vetoes=("unsupported_carrier",)),)
|
||
d = controller_decide(_inp(branches))
|
||
assert d.label == "QUARANTINE"
|
||
assert "ALL_BRANCHES_VETOED" in d.notes
|
||
assert d.veto_reasons["a"] == ("unsupported_carrier",)
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# §16.2 #6 — test_cache_drift_quarantines
|
||
# ---------------------------------------------------------------------
|
||
|
||
|
||
def test_cache_drift_quarantines():
|
||
"""`cache_drift` veto on the only branch → QUARANTINE."""
|
||
branches = (_b("a", hard_vetoes=("cache_drift",)),)
|
||
d = controller_decide(_inp(branches))
|
||
assert d.label == "QUARANTINE"
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# §16.2 #7 — test_memory_invalidation_above_threshold_escalates
|
||
# ---------------------------------------------------------------------
|
||
|
||
|
||
def test_memory_invalidation_above_threshold_escalates():
|
||
"""Selected branch with memory_invalidation >= KAPPA_MEMORY →
|
||
ESCALATE."""
|
||
branches = (
|
||
_b(
|
||
"a",
|
||
delta_5s=0.5,
|
||
delta_5t=0.5,
|
||
delta_5f=0.5,
|
||
memory_invalidation=KAPPA_MEMORY,
|
||
),
|
||
)
|
||
d = controller_decide(_inp(branches))
|
||
assert d.label == "ESCALATE"
|
||
assert d.selected_branch_id == "a"
|
||
assert "MEMORY_INVALIDATION_ABOVE_KAPPA" in d.notes
|
||
|
||
# Just below threshold → no escalation.
|
||
branches = (
|
||
_b(
|
||
"a",
|
||
delta_5s=0.5,
|
||
delta_5t=0.5,
|
||
delta_5f=0.5,
|
||
memory_invalidation=KAPPA_MEMORY - 0.01,
|
||
),
|
||
)
|
||
d = controller_decide(_inp(branches))
|
||
assert d.label != "ESCALATE"
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# §16.2 #8 — test_high_entropy_increases_difficulty
|
||
# ---------------------------------------------------------------------
|
||
|
||
|
||
def test_high_entropy_increases_difficulty():
|
||
"""Multiple equally-attractive branches → high entropy →
|
||
difficulty_next > difficulty (§7.1 H_norm term is positive).
|
||
|
||
Note: under default `witness_agreement_rate=1.0` the agreement
|
||
pull exactly cancels the entropy drive (both weight 0.1).
|
||
Setting agreement_rate=0 isolates the entropy contribution."""
|
||
# Three branches, identical utilities → uniform distribution →
|
||
# H_norm = 1.0.
|
||
branches = tuple(
|
||
_b(f"b{i}", delta_5s=0.05, delta_5t=0.05, delta_5f=0.05)
|
||
for i in range(3)
|
||
)
|
||
initial_difficulty = 1.0
|
||
d = controller_decide(
|
||
_inp(
|
||
branches,
|
||
difficulty=initial_difficulty,
|
||
witness_agreement_rate=0.0,
|
||
)
|
||
)
|
||
assert d.entropy >= H_HIGH, (
|
||
f"three-equal-branch H_norm should be near 1.0, got {d.entropy}"
|
||
)
|
||
assert d.difficulty_next > initial_difficulty
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# §16.2 #9 — test_low_entropy_decreases_or_preserves_difficulty
|
||
# ---------------------------------------------------------------------
|
||
|
||
|
||
def test_low_entropy_decreases_or_preserves_difficulty():
|
||
"""Single overwhelmingly-good branch → low entropy → with
|
||
`witness_agreement_rate=1.0` and zero divergence/regression
|
||
pressure, difficulty_next <= difficulty."""
|
||
branches = (
|
||
_b("dominant", delta_5s=10.0, delta_5t=10.0, delta_5f=10.0),
|
||
_b("weak", delta_5s=-5.0, delta_5t=-5.0, delta_5f=-5.0),
|
||
)
|
||
initial_difficulty = 5.0
|
||
d = controller_decide(
|
||
_inp(
|
||
branches,
|
||
difficulty=initial_difficulty,
|
||
witness_agreement_rate=1.0,
|
||
divergence_rate=0.0,
|
||
hard_regression_rate=0.0,
|
||
)
|
||
)
|
||
# Entropy near zero (dominant branch has p ≈ 1).
|
||
assert d.entropy <= H_LOW
|
||
# With agreement pull > entropy drive in this regime, difficulty
|
||
# should not climb.
|
||
assert d.difficulty_next <= initial_difficulty
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# §16.2 #10 — test_divergence_increases_witness_sampling_recommendation
|
||
# ---------------------------------------------------------------------
|
||
|
||
|
||
def test_divergence_increases_witness_sampling_recommendation():
|
||
"""High `divergence_rate` → higher difficulty_next (the controller's
|
||
homeostat: divergence is the thermostat; more difficulty means
|
||
wider witness sampling in the Phase 3 sweep)."""
|
||
branches = (_b("a", delta_5s=0.1, delta_5t=0.1, delta_5f=0.1),)
|
||
initial = 1.0
|
||
|
||
low_div = controller_decide(
|
||
_inp(branches, difficulty=initial, divergence_rate=0.0)
|
||
)
|
||
high_div = controller_decide(
|
||
_inp(branches, difficulty=initial, divergence_rate=0.8)
|
||
)
|
||
assert high_div.difficulty_next > low_div.difficulty_next
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# §16.2 #11 — test_no_llm_call_in_controller
|
||
# ---------------------------------------------------------------------
|
||
|
||
|
||
def _module_imports() -> set[str]:
|
||
"""Parse the prometheus.py source and return the set of imported
|
||
module names. Catches `import x`, `import x.y`, `from x.y import …`."""
|
||
here = Path(__file__).resolve().parent
|
||
src = (here.parent / "arborist" / "substrate" / "prometheus.py").read_text(
|
||
encoding="utf-8"
|
||
)
|
||
tree = ast.parse(src)
|
||
names: set[str] = set()
|
||
for node in ast.walk(tree):
|
||
if isinstance(node, ast.Import):
|
||
for alias in node.names:
|
||
names.add(alias.name)
|
||
elif isinstance(node, ast.ImportFrom):
|
||
if node.module:
|
||
names.add(node.module)
|
||
return names
|
||
|
||
|
||
def test_no_llm_call_in_controller():
|
||
"""Pure function — must not import the LLM gateway."""
|
||
imports = _module_imports()
|
||
assert "arborist.qa.client" not in imports, (
|
||
"controller must not import the LLM client (Phase 1 is pure)"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# §16.2 #12 — test_controller_does_not_modify_cache_key_inputs
|
||
# ---------------------------------------------------------------------
|
||
|
||
|
||
def test_controller_does_not_modify_cache_key_inputs():
|
||
"""Phase 1 controller must not touch the cache_key path. AST
|
||
inspection confirms `arborist.qa.keys` is not imported."""
|
||
imports = _module_imports()
|
||
assert "arborist.qa.keys" not in imports, (
|
||
"controller must not import the cache_key path (Phase 1 is "
|
||
"advisory-only over already-committed state)"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# §16.2 #13 — test_controller_outputs_advisory_event_only
|
||
# ---------------------------------------------------------------------
|
||
|
||
|
||
def test_controller_outputs_advisory_event_only():
|
||
"""An ACCEPT-bound input produces `advisory_events` as a tuple of
|
||
(str, dict) pairs. Phase 2 will persist these; Phase 1 only
|
||
builds the data structure."""
|
||
branches = (_b("a", delta_5s=0.5, delta_5t=0.5, delta_5f=0.5),)
|
||
d = controller_decide(_inp(branches))
|
||
assert d.label == "ACCEPT"
|
||
assert isinstance(d.advisory_events, tuple)
|
||
assert len(d.advisory_events) >= 1
|
||
for entry in d.advisory_events:
|
||
assert isinstance(entry, tuple) and len(entry) == 2
|
||
kind, body = entry
|
||
assert isinstance(kind, str)
|
||
assert isinstance(body, dict)
|
||
# No mutation API was called — verified by `memory_proposals` /
|
||
# `selfmodel_proposals` being plain tuples of frozen dataclasses
|
||
# (no side-effects possible).
|
||
assert isinstance(d.memory_proposals, tuple)
|
||
assert isinstance(d.selfmodel_proposals, tuple)
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# §16.2 #14 — test_zero_mean_divergence_does_not_trigger_phase_1
|
||
# ---------------------------------------------------------------------
|
||
|
||
|
||
def test_zero_mean_divergence_does_not_trigger_phase_1():
|
||
"""Mean divergence = 0 + zero stddev → no signal → no trigger.
|
||
Absolute-stddev fallback path used when mean is zero but variance
|
||
is present."""
|
||
# All-zero samples → mean = 0, stddev = 0 → no_variance_signal.
|
||
triggered, reason = phase_1_trigger_check([0.0] * 40)
|
||
assert triggered is False
|
||
assert reason == "no_variance_signal"
|
||
|
||
# Mean = 0 but nonzero stddev below the absolute threshold.
|
||
samples = [0.0, 0.001, -0.001, 0.0] * 10 # n=40, mean=0, tiny stddev
|
||
triggered, reason = phase_1_trigger_check(samples)
|
||
assert triggered is False
|
||
assert reason == "absolute_stddev_path"
|
||
|
||
# Mean = 0 with nonzero stddev above the absolute threshold → fires.
|
||
# Balance positive and negative so the mean stays at 0.
|
||
samples = [0.2, -0.2] * 20 # n=40, mean=0, stddev=0.2 >= 0.05.
|
||
triggered, reason = phase_1_trigger_check(samples)
|
||
assert triggered is True
|
||
assert reason == "absolute_stddev_path"
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# §16.2 #15 — test_small_sample_does_not_trigger_phase_1
|
||
# ---------------------------------------------------------------------
|
||
|
||
|
||
def test_small_sample_does_not_trigger_phase_1():
|
||
"""sample_count < N_min (= 30) → no trigger regardless of variance.
|
||
Tiny samples are noise."""
|
||
# 29 samples — high variance, below the floor.
|
||
rng = random.Random(0)
|
||
samples = [rng.uniform(0.0, 1.0) for _ in range(N_MIN_TRIGGER_2 - 1)]
|
||
triggered, reason = phase_1_trigger_check(samples)
|
||
assert triggered is False
|
||
assert reason == "small_sample"
|
||
|
||
# Empty sample — also small.
|
||
triggered, reason = phase_1_trigger_check([])
|
||
assert triggered is False
|
||
assert reason == "small_sample"
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# §16.2 #16 — test_propose_not_mutate_memory_root
|
||
# ---------------------------------------------------------------------
|
||
|
||
|
||
def test_propose_not_mutate_memory_root():
|
||
"""A vetoed branch with regression_penalty > 0 yields a
|
||
MemoryRootUpdateProposal record. Type is the proposal dataclass
|
||
(no mutation API)."""
|
||
branches = (
|
||
_b(
|
||
"regressed",
|
||
hard_vetoes=("hard_regression",),
|
||
regression_penalty=0.4,
|
||
),
|
||
_b("live", delta_5s=0.1),
|
||
)
|
||
d = controller_decide(_inp(branches))
|
||
assert len(d.memory_proposals) == 1
|
||
prop = d.memory_proposals[0]
|
||
assert isinstance(prop, MemoryRootUpdateProposal)
|
||
assert prop.branch_id == "regressed"
|
||
assert "BRANCH_REGRESSED" in prop.proposed_invalidation_tags
|
||
# Frozen dataclass — no mutation surface.
|
||
with pytest.raises((AttributeError, TypeError)):
|
||
prop.proposed_invalidation_tags = () # type: ignore[misc]
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# §16.2 #17 — test_propose_not_mutate_self_model
|
||
# ---------------------------------------------------------------------
|
||
|
||
|
||
def test_propose_not_mutate_self_model():
|
||
"""A selected branch with selfmodel_calibration_gain != 0 yields a
|
||
SelfModelUpdateProposal record. Type is the proposal dataclass
|
||
(no SelfModel mutation API call)."""
|
||
branches = (
|
||
_b(
|
||
"calibrating",
|
||
delta_5s=0.5,
|
||
delta_5t=0.5,
|
||
delta_5f=0.5,
|
||
selfmodel_calibration_gain=0.15,
|
||
),
|
||
)
|
||
d = controller_decide(_inp(branches))
|
||
assert d.selected_branch_id == "calibrating"
|
||
assert len(d.selfmodel_proposals) == 1
|
||
prop = d.selfmodel_proposals[0]
|
||
assert isinstance(prop, SelfModelUpdateProposal)
|
||
assert prop.calibration_delta == 0.15
|
||
assert prop.reason == "controller_phase_1_calibration"
|
||
with pytest.raises((AttributeError, TypeError)):
|
||
prop.calibration_delta = 0.0 # type: ignore[misc]
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# Additional sanity checks (not in §16.2 — harden the pure-function
|
||
# contract)
|
||
# ---------------------------------------------------------------------
|
||
|
||
|
||
def test_idempotent_byte_identical_output():
|
||
"""Pure function: two invocations with identical inputs produce
|
||
byte-identical ControllerDecision."""
|
||
branches = (
|
||
_b("a", delta_5s=0.1, delta_5t=0.2, delta_5f=0.3),
|
||
_b("b", delta_5s=-0.1, delta_5t=0.0, delta_5f=0.2, capital_cost=1.0),
|
||
)
|
||
inp = _inp(branches)
|
||
d1 = controller_decide(inp)
|
||
d2 = controller_decide(inp)
|
||
assert d1 == d2
|
||
|
||
|
||
def test_weight_profiles_registry_complete():
|
||
"""All four named profiles present and structurally distinct."""
|
||
assert set(WEIGHT_PROFILES) == {
|
||
"safe",
|
||
"conservative",
|
||
"exploratory",
|
||
"sweep",
|
||
}
|
||
safe = WEIGHT_PROFILES["safe"]
|
||
cons = WEIGHT_PROFILES["conservative"]
|
||
expl = WEIGHT_PROFILES["exploratory"]
|
||
swp = WEIGHT_PROFILES["sweep"]
|
||
# §15.2 deltas
|
||
assert cons.gamma_5f == 1.0
|
||
assert cons.lambda_capital_cost == 1.5
|
||
assert cons.xi_security_risk == 4.0
|
||
assert cons.eta_softmax_temperature == 0.75
|
||
# §15.3 deltas
|
||
assert expl.gamma_5f == 1.5
|
||
assert expl.nu_witness_divergence == 0.75
|
||
assert expl.lambda_capital_cost == 0.5
|
||
assert expl.eta_softmax_temperature == 1.5
|
||
# Sweep profile deltas (Phase 1.c — dry-run Finding #3)
|
||
assert swp.gamma_5f == 1.5
|
||
assert swp.lambda_capital_cost == 0.25
|
||
assert swp.nu_witness_divergence == 0.5
|
||
# Safe baseline
|
||
assert safe.gamma_5f == 1.25
|
||
|
||
|
||
def test_phase_1_trigger_check_positive_path():
|
||
"""Coefficient-of-variation path fires when the sample has clear
|
||
nonzero mean + comparable stddev."""
|
||
rng = random.Random(1)
|
||
# Mean ≈ 0.3, stddev ≈ 0.2 → CoV ≈ 0.67 >= 0.5 → fires.
|
||
samples = [rng.uniform(0.0, 0.6) for _ in range(60)]
|
||
triggered, reason = phase_1_trigger_check(samples)
|
||
# Mean and stddev depend on the seeded RNG, but the seed is
|
||
# fixed → result is deterministic. Sanity-check the reason set.
|
||
assert reason in {
|
||
"variance_threshold",
|
||
"variance_below_threshold",
|
||
"absolute_stddev_path",
|
||
}
|
||
if triggered:
|
||
assert reason in {"variance_threshold", "absolute_stddev_path"}
|
||
|
||
|
||
def test_empty_branches_returns_unknown():
|
||
"""No candidate branches → UNKNOWN with NO_CANDIDATE_BRANCHES note
|
||
(§14 row 1)."""
|
||
d = controller_decide(_inp(branches=()))
|
||
assert d.label == "UNKNOWN"
|
||
assert d.selected_branch_id is None
|
||
assert "NO_CANDIDATE_BRANCHES" in d.notes
|
||
|
||
|
||
def test_decision_is_a_controllerdecision():
|
||
"""Output type sanity."""
|
||
branches = (_b("a", delta_5s=0.1),)
|
||
d = controller_decide(_inp(branches))
|
||
assert isinstance(d, ControllerDecision)
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# §14 row 4 — Hermes-saturation guard
|
||
# ---------------------------------------------------------------------
|
||
|
||
|
||
def test_hermes_saturation_returns_deferred():
|
||
"""When hermes_utilization >= budget, no headroom remains; the
|
||
controller defers regardless of branch utility (§14 row 4)."""
|
||
branches = (_b("good", delta_5s=0.5, delta_5f=0.5),)
|
||
inp = _inp(branches, budget=4, hermes_utilization=4)
|
||
d = controller_decide(inp)
|
||
assert d.label == "DEFERRED"
|
||
assert "HERMES_SATURATED" in d.notes
|
||
# Allocations stay all-zero — no compute spent under saturation.
|
||
assert all(v == 0.0 for v in d.allocations.values())
|
||
# Advisory event records the saturation context for Phase 2 audit.
|
||
kinds = [k for (k, _) in d.advisory_events]
|
||
assert "controller_decision" in kinds
|
||
body = next(b for (k, b) in d.advisory_events if k == "controller_decision")
|
||
assert body["reason"] == "HERMES_SATURATED"
|
||
assert body["hermes_utilization"] == 4
|
||
assert body["budget"] == 4
|
||
|
||
|
||
def test_hermes_saturation_above_budget_also_defers():
|
||
"""utilization > budget (overflow case) still defers — the guard
|
||
is ``utilization >= budget``, not just equality."""
|
||
branches = (_b("a"),)
|
||
d = controller_decide(_inp(branches, budget=4, hermes_utilization=8))
|
||
assert d.label == "DEFERRED"
|
||
assert "HERMES_SATURATED" in d.notes
|
||
|
||
|
||
def test_hermes_saturation_does_not_fire_when_headroom_exists():
|
||
"""utilization < budget → controller proceeds normally."""
|
||
branches = (_b("good", delta_5f=0.5, payoff_b=10.0),)
|
||
inp = _inp(branches, budget=4, hermes_utilization=1)
|
||
d = controller_decide(inp)
|
||
assert d.label != "DEFERRED" or "HERMES_SATURATED" not in d.notes
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# §6 + §14 — additional veto-class tests (replay/soft-hash) +
|
||
# defensive NUMERIC_INSTABILITY path
|
||
# ---------------------------------------------------------------------
|
||
|
||
|
||
def test_replay_window_unbounded_escalates():
|
||
"""§14 row + §6: `replay_window_unbounded` is an ESCALATE-class
|
||
veto (T3 still open per #000036)."""
|
||
branches = (_b("a", hard_vetoes=("replay_window_unbounded",)),)
|
||
d = controller_decide(_inp(branches))
|
||
assert d.label == "ESCALATE"
|
||
assert "ALL_BRANCHES_VETOED" in d.notes
|
||
|
||
|
||
def test_soft_hash_signal_quarantines():
|
||
"""§14 row 10 + §6: `soft_hash_signal` above threshold is a
|
||
QUARANTINE-class veto."""
|
||
branches = (_b("a", hard_vetoes=("soft_hash_signal",)),)
|
||
d = controller_decide(_inp(branches))
|
||
assert d.label == "QUARANTINE"
|
||
|
||
|
||
def test_escalate_dominates_quarantine_dominates_reject():
|
||
"""When a chunk has mixed veto classes the cascade returns the
|
||
most-severe class present (§6 fail-loud ordering)."""
|
||
branches = (
|
||
_b("escalate", hard_vetoes=("replay_window_unbounded",)),
|
||
_b("quarantine", hard_vetoes=("cache_drift",)),
|
||
_b("reject", hard_vetoes=("verifier_failure",)),
|
||
)
|
||
d = controller_decide(_inp(branches))
|
||
assert d.label == "ESCALATE"
|
||
|
||
|
||
def test_quarantine_dominates_reject():
|
||
branches = (
|
||
_b("quarantine", hard_vetoes=("schema_mismatch",)),
|
||
_b("reject", hard_vetoes=("hard_regression",)),
|
||
)
|
||
d = controller_decide(_inp(branches))
|
||
assert d.label == "QUARANTINE"
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# §13 Step 11 — falsification-fixture proposal emission
|
||
# ---------------------------------------------------------------------
|
||
|
||
|
||
def test_high_divergence_emits_falsification_proposal():
|
||
"""A branch with witness_divergence ≥ threshold gets a
|
||
FalsificationFixtureProposal record — the §3 'divergence →
|
||
candidate fixture' funnel for 5F mining (#000025)."""
|
||
branches = (
|
||
_b("a", delta_5f=0.1, payoff_b=5.0),
|
||
_b("diverged", witness_divergence=0.8, delta_5f=0.05),
|
||
)
|
||
d = controller_decide(_inp(branches))
|
||
proposals = d.falsification_proposals
|
||
assert any(p.branch_id == "diverged" for p in proposals)
|
||
p = next(p for p in proposals if p.branch_id == "diverged")
|
||
assert p.witness_divergence == 0.8
|
||
assert p.reason == "WITNESS_DIVERGENCE_EXCEEDS_THRESHOLD"
|
||
assert p.organism_root == "Ω0"
|
||
|
||
|
||
def test_low_divergence_does_not_emit_proposal():
|
||
"""A branch under threshold doesn't produce a fixture proposal."""
|
||
branches = (_b("a", witness_divergence=0.1, delta_5f=0.1, payoff_b=5.0),)
|
||
d = controller_decide(_inp(branches))
|
||
assert d.falsification_proposals == ()
|
||
|
||
|
||
def test_falsification_threshold_is_weight_tunable():
|
||
"""ControllerWeights.falsification_divergence_threshold gates the
|
||
proposal — lowering it surfaces more proposals."""
|
||
w_sensitive = ControllerWeights(
|
||
falsification_divergence_threshold=0.05
|
||
)
|
||
branches = (_b("a", witness_divergence=0.10, delta_5f=0.1, payoff_b=5.0),)
|
||
d = controller_decide(_inp(branches, weights=w_sensitive))
|
||
assert len(d.falsification_proposals) == 1
|
||
assert d.falsification_proposals[0].branch_id == "a"
|
||
|
||
|
||
def test_falsification_proposal_emitted_even_on_vetoed_branch():
|
||
"""Divergence-as-falsification-signal is independent of the
|
||
accept/reject decision — even a vetoed branch can produce a
|
||
fixture proposal when its divergence is high."""
|
||
branches = (
|
||
_b("vetoed", hard_vetoes=("cache_drift",), witness_divergence=0.9),
|
||
)
|
||
d = controller_decide(_inp(branches))
|
||
# All-vetoed cascade still emits the proposal — Phase 1 surfaces
|
||
# the divergence signal at every level.
|
||
# (Implementation choice: proposals emit from ALL branches, not
|
||
# just selected. Vetoed-AND-diverged is a 1-fixture deliverable.)
|
||
assert any(p.branch_id == "vetoed" for p in d.falsification_proposals)
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# §14 + §5 — entropy gating becomes weight-tunable (h_low / h_high)
|
||
# ---------------------------------------------------------------------
|
||
|
||
|
||
def test_h_low_h_high_are_weight_tunable():
|
||
"""Moving entropy thresholds to ControllerWeights lets deployments
|
||
override the §5 default (0.3 / 0.7) without patching the module."""
|
||
# Push h_high above 1.0 so even max-entropy chunks ACCEPT.
|
||
w = ControllerWeights(h_low=0.5, h_high=1.1)
|
||
branches = (
|
||
_b("a", delta_5f=0.10, payoff_b=10.0),
|
||
_b("b", delta_5f=0.09, payoff_b=10.0),
|
||
)
|
||
d = controller_decide(_inp(branches, weights=w))
|
||
# With h_high=1.1 the high-entropy branch never trips MARGINAL.
|
||
assert d.label != "MARGINAL"
|
||
|
||
|
||
def test_kappa_memory_is_weight_tunable():
|
||
"""ControllerWeights.kappa_memory governs the escalate-threshold.
|
||
|
||
Utility math (default safe weights):
|
||
U = γ·δ5F − ω·memory_invalidation
|
||
= 1.25·0.5 − 2.0·0.15
|
||
= 0.625 − 0.3 = +0.325 > 0
|
||
So we clear the NON_POSITIVE_UTILITY guard, and reach the kappa
|
||
branch with `memory_invalidation=0.15 >= kappa_memory=0.1` →
|
||
ESCALATE.
|
||
"""
|
||
w = ControllerWeights(kappa_memory=0.1)
|
||
branches = (
|
||
_b("a", delta_5f=0.5, memory_invalidation=0.15, payoff_b=10.0),
|
||
)
|
||
d = controller_decide(_inp(branches, weights=w))
|
||
assert d.label == "ESCALATE"
|
||
assert "MEMORY_INVALIDATION_ABOVE_KAPPA" in d.notes
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# Module-level constants stay back-compat exports
|
||
# ---------------------------------------------------------------------
|
||
|
||
|
||
def test_module_level_constants_match_default_weights():
|
||
"""``H_LOW``, ``H_HIGH``, ``KAPPA_MEMORY`` are kept at module level
|
||
for back-compat with callers that referenced them pre-v2; the
|
||
weight-field defaults must match exactly so behavior is identical
|
||
when neither is overridden."""
|
||
w = ControllerWeights()
|
||
assert w.h_low == H_LOW
|
||
assert w.h_high == H_HIGH
|
||
assert w.kappa_memory == KAPPA_MEMORY
|
||
|
||
|
||
# ---------------------------------------------------------------------
|
||
# Phase 1.c — kernel_cost / llm_cost split on ControllerBranch
|
||
# ---------------------------------------------------------------------
|
||
|
||
|
||
def test_effective_cost_uses_split_when_provided():
|
||
"""When ``kernel_cost`` or ``llm_cost`` > 0, ``effective_cost``
|
||
returns their sum and IGNORES the legacy ``capital_cost`` field.
|
||
This is the Phase 1.c migration path."""
|
||
b = _b("a", capital_cost=1.0)
|
||
b_split = ControllerBranch(
|
||
branch_id="b",
|
||
deltas=BatteryDeltas(0, 0, 0, 0),
|
||
witness_divergence=0.0,
|
||
capital_cost=999.0, # SHOULD BE IGNORED
|
||
kernel_cost=0.05,
|
||
llm_cost=0.3,
|
||
)
|
||
# Legacy branch uses capital_cost directly:
|
||
assert b.effective_cost == 1.0
|
||
# Split branch sums kernel + llm, ignores legacy:
|
||
assert b_split.effective_cost == 0.35
|
||
|
||
|
||
def test_effective_cost_falls_back_when_split_zero():
|
||
"""When both new fields are 0, ``effective_cost`` reads
|
||
``capital_cost`` — preserving pre-Phase-1.c behavior."""
|
||
b = ControllerBranch(
|
||
branch_id="legacy",
|
||
deltas=BatteryDeltas(0, 0, 0, 0),
|
||
witness_divergence=0.0,
|
||
capital_cost=0.42,
|
||
# kernel_cost + llm_cost both default to 0.0
|
||
)
|
||
assert b.effective_cost == 0.42
|
||
|
||
|
||
def test_utility_uses_effective_cost():
|
||
"""The utility formula reads effective_cost so a split-cost
|
||
branch and a legacy-cost branch with the same TOTAL cost produce
|
||
byte-identical utility values."""
|
||
legacy = ControllerBranch(
|
||
branch_id="legacy",
|
||
deltas=BatteryDeltas(0.1, 0.1, 0.1, 0.1),
|
||
witness_divergence=0.0,
|
||
capital_cost=0.5,
|
||
)
|
||
split = ControllerBranch(
|
||
branch_id="split",
|
||
deltas=BatteryDeltas(0.1, 0.1, 0.1, 0.1),
|
||
witness_divergence=0.0,
|
||
capital_cost=0.0,
|
||
kernel_cost=0.1,
|
||
llm_cost=0.4,
|
||
)
|
||
w = safe_weights()
|
||
from arborist.substrate.prometheus import _utility
|
||
assert _utility(legacy, w) == _utility(split, w)
|
||
|
||
|
||
def test_kernel_only_branch_cheaper_than_llm_branch():
|
||
"""Two branches with identical signal but different cost class
|
||
(kernel-only vs LLM-witness) produce different utilities,
|
||
favoring the kernel-only path."""
|
||
kernel_only = ControllerBranch(
|
||
branch_id="kernel",
|
||
deltas=BatteryDeltas(0, 0, 0.05, 0),
|
||
witness_divergence=0.0,
|
||
kernel_cost=0.05,
|
||
llm_cost=0.0,
|
||
payoff_b=10.0,
|
||
)
|
||
llm_only = ControllerBranch(
|
||
branch_id="llm",
|
||
deltas=BatteryDeltas(0, 0, 0.05, 0),
|
||
witness_divergence=0.0,
|
||
kernel_cost=0.0,
|
||
llm_cost=1.0,
|
||
payoff_b=10.0,
|
||
)
|
||
from arborist.substrate.prometheus import _utility
|
||
w = safe_weights()
|
||
assert _utility(kernel_only, w) > _utility(llm_only, w)
|
||
|
||
|
||
def test_split_cost_branches_route_through_controller():
|
||
"""End-to-end: a split-cost branch can be fed to controller_decide
|
||
and the decision uses effective_cost in the utility math."""
|
||
branches = (
|
||
ControllerBranch(
|
||
branch_id="kernel",
|
||
deltas=BatteryDeltas(0, 0, 0.10, 0),
|
||
witness_divergence=0.0,
|
||
kernel_cost=0.05,
|
||
llm_cost=0.0,
|
||
payoff_b=10.0,
|
||
),
|
||
ControllerBranch(
|
||
branch_id="llm",
|
||
deltas=BatteryDeltas(0, 0, 0.05, 0),
|
||
witness_divergence=0.0,
|
||
kernel_cost=0.0,
|
||
llm_cost=1.0,
|
||
payoff_b=1.0,
|
||
),
|
||
)
|
||
d = controller_decide(_inp(branches))
|
||
# The kernel branch is unambiguously the winner under safe
|
||
# weights: higher delta_5f, lower cost.
|
||
assert d.selected_branch_id == "kernel"
|
||
|
||
|
||
def test_sweep_profile_accepts_what_safe_profile_defers():
|
||
"""The Phase-1.c sweep profile drops lambda_capital_cost from
|
||
1.0 → 0.25 so the sweep is willing to pay capital for
|
||
falsification discovery. A marginal-cost branch that DEFERREDs
|
||
under safe weights ACCEPTs under sweep weights."""
|
||
from arborist.substrate.prometheus import sweep_weights
|
||
|
||
# Build a branch whose utility under safe weights is barely
|
||
# negative (DEFERRED via Kelly's p_i > 0.5 unmet); under sweep
|
||
# weights its utility flips positive.
|
||
# safe: U = 1.25 * 0.10 - 1.0 * 0.30 = -0.175 (< 0)
|
||
# sweep: U = 1.50 * 0.10 - 0.25 * 0.30 = +0.075 (> 0)
|
||
branches = (
|
||
ControllerBranch(
|
||
branch_id="marginal",
|
||
deltas=BatteryDeltas(0, 0, 0.10, 0),
|
||
witness_divergence=0.0,
|
||
capital_cost=0.30,
|
||
payoff_b=10.0,
|
||
),
|
||
)
|
||
safe_d = controller_decide(_inp(branches, weights=safe_weights()))
|
||
sweep_d = controller_decide(_inp(branches, weights=sweep_weights()))
|
||
# Safe-profile decision rejects the marginal branch.
|
||
assert safe_d.label == "REJECT"
|
||
# Sweep-profile decision accepts it.
|
||
assert sweep_d.label in ("ACCEPT", "MARGINAL")
|