diff --git a/arborist/substrate/prometheus.py b/arborist/substrate/prometheus.py index 9820ca6..2b1b7f7 100644 --- a/arborist/substrate/prometheus.py +++ b/arborist/substrate/prometheus.py @@ -183,6 +183,21 @@ class ControllerWeights: min_difficulty: float = 0.0 max_difficulty: float = 10.0 + # §5 entropy gating + §14 memory-invalidation κ. + # Promoted from module-level constants to weight fields so + # deployments can tune them via the profile mechanism without + # patching the module. Defaults match :data:`H_LOW` / + # :data:`H_HIGH` / :data:`KAPPA_MEMORY`. + h_low: float = 0.3 + h_high: float = 0.7 + kappa_memory: float = 0.5 + + # §13 Step 11 — falsification-fixture proposal threshold. When a + # branch's ``witness_divergence`` exceeds this, the controller + # emits a :class:`FalsificationFixtureProposal` for downstream + # 5F-fixture mining. 0.5 = "divergence dominates verifier signal." + falsification_divergence_threshold: float = 0.5 + @dataclass(frozen=True) class ControllerInput: @@ -236,6 +251,23 @@ class SelfModelUpdateProposal: reason: str +@dataclass(frozen=True) +class FalsificationFixtureProposal: + """§13 Step 11 — proposal record for a candidate 5F fixture. + + Emitted when a branch's ``witness_divergence`` exceeds + :attr:`ControllerWeights.falsification_divergence_threshold`. + The 5F battery's falsification fixture-set (#000025) is the + intended sink. Phase 1 only proposes; downstream fixture + authors decide whether to admit the proposal. + """ + + organism_root: str + branch_id: str + witness_divergence: float + reason: str + + @dataclass(frozen=True) class ControllerDecision: """§13 step-9 output. Advisory; Phase 2 persists ``advisory_events``.""" @@ -249,6 +281,7 @@ class ControllerDecision: notes: tuple[str, ...] memory_proposals: tuple[MemoryRootUpdateProposal, ...] = () selfmodel_proposals: tuple[SelfModelUpdateProposal, ...] = () + falsification_proposals: tuple[FalsificationFixtureProposal, ...] = () advisory_events: tuple[tuple[str, dict], ...] = () @@ -483,6 +516,17 @@ def controller_decide(inp: ControllerInput) -> ControllerDecision: notes=("NO_CANDIDATE_BRANCHES",), ) + # §13 Step 11 — falsification-fixture proposals fire BEFORE the + # all-vetoed early-return. Divergence is an empirical signal + # independent of accept/reject; a vetoed-AND-diverged branch is + # still a 5F-fixture candidate. Emission lives in a helper so the + # all-vetoed and normal paths use identical logic. + falsification_proposals = _emit_falsification_proposals( + inp.organism_root, + branches, + threshold=inp.weights.falsification_divergence_threshold, + ) + # ---- Step 1 + Step 3: hard-veto sweep ---- unvetoed = [b for b in branches if not b.hard_vetoes] if not unvetoed: @@ -514,6 +558,7 @@ def controller_decide(inp: ControllerInput) -> ControllerDecision: entropy=0.0, notes=("ALL_BRANCHES_VETOED",), memory_proposals=memory_proposals, + falsification_proposals=falsification_proposals, advisory_events=advisory_events, ) @@ -537,6 +582,36 @@ def controller_decide(inp: ControllerInput) -> ControllerDecision: # ---- Step 8: Kelly-bounded allocation under B ---- allocations: dict[str, float] = {b.branch_id: 0.0 for b in branches} + # §14 row 4: Hermes unavailable / saturated → DEFERRED. Modeled + # as ``hermes_utilization >= budget`` (no headroom for new calls). + # Sleep-time scheduling can pick the work back up once the + # endpoint frees up. + if inp.hermes_utilization >= inp.budget and inp.budget > 0: + advisory_events = ( + ( + "controller_decision", + { + "organism_root": inp.organism_root, + "label": "DEFERRED", + "reason": "HERMES_SATURATED", + "entropy": h_norm, + "hermes_utilization": inp.hermes_utilization, + "budget": inp.budget, + }, + ), + ) + return ControllerDecision( + selected_branch_id=None, + label="DEFERRED", + allocations=allocations, + difficulty_next=difficulty_next, + veto_reasons=veto_reasons, + entropy=h_norm, + notes=("HERMES_SATURATED",), + falsification_proposals=falsification_proposals, + advisory_events=advisory_events, + ) + # §14 row: Budget B = 0 → DEFERRED for everything. if inp.budget == 0: advisory_events = ( @@ -558,6 +633,7 @@ def controller_decide(inp: ControllerInput) -> ControllerDecision: veto_reasons=veto_reasons, entropy=h_norm, notes=("ZERO_BUDGET",), + falsification_proposals=falsification_proposals, advisory_events=advisory_events, ) @@ -607,6 +683,7 @@ def controller_decide(inp: ControllerInput) -> ControllerDecision: veto_reasons=veto_reasons, entropy=h_norm, notes=("ZERO_RAW_ALLOCATION",), + falsification_proposals=falsification_proposals, advisory_events=advisory_events, ) @@ -633,6 +710,7 @@ def controller_decide(inp: ControllerInput) -> ControllerDecision: veto_reasons=veto_reasons, entropy=h_norm, notes=("NUMERIC_INSTABILITY",), + falsification_proposals=falsification_proposals, advisory_events=advisory_events, ) @@ -656,13 +734,13 @@ def controller_decide(inp: ControllerInput) -> ControllerDecision: elif selected_u <= 0.0: label = "REJECT" reason_notes.append("NON_POSITIVE_UTILITY") - elif selected.memory_invalidation >= KAPPA_MEMORY: + elif selected.memory_invalidation >= w.kappa_memory: label = "ESCALATE" reason_notes.append("MEMORY_INVALIDATION_ABOVE_KAPPA") - elif h_norm <= H_LOW: + elif h_norm <= w.h_low: label = "ACCEPT" reason_notes.append("LOW_ENTROPY") - elif h_norm >= H_HIGH: + elif h_norm >= w.h_high: label = "MARGINAL" reason_notes.append("HIGH_ENTROPY") else: @@ -684,6 +762,11 @@ def controller_decide(inp: ControllerInput) -> ControllerDecision: ), ) + # Falsification proposals were emitted at the top of + # controller_decide (above the all-vetoed cascade) so vetoed-AND- + # diverged branches still produce 5F fixture candidates. The value + # captured up there is the one we return. + # ---- Advisory event (§13 step 10; persisted by Phase 2) ---- advisory_events: tuple[tuple[str, dict], ...] = ( ( @@ -728,6 +811,7 @@ def controller_decide(inp: ControllerInput) -> ControllerDecision: notes=tuple(notes), memory_proposals=memory_proposals, selfmodel_proposals=selfmodel_proposals, + falsification_proposals=falsification_proposals, advisory_events=advisory_events, ) @@ -754,6 +838,32 @@ def _emit_memory_proposals( return tuple(proposals) +def _emit_falsification_proposals( + organism_root: str, + branches: Sequence[ControllerBranch], + *, + threshold: float, +) -> tuple[FalsificationFixtureProposal, ...]: + """§13 Step 11 — propose 5F-fixture candidates from divergence. + + Any branch whose ``witness_divergence`` meets or exceeds the + configured threshold gets a :class:`FalsificationFixtureProposal` + record. Emission is independent of veto state — divergence is an + empirical signal that a fixture could capture regardless of + whether the controller would accept the branch otherwise. + """ + return tuple( + FalsificationFixtureProposal( + organism_root=organism_root, + branch_id=b.branch_id, + witness_divergence=b.witness_divergence, + reason="WITNESS_DIVERGENCE_EXCEEDS_THRESHOLD", + ) + for b in branches + if b.witness_divergence >= threshold + ) + + __all__ = [ # Constants "VETO_CLASS", @@ -772,6 +882,7 @@ __all__ = [ "ControllerDecision", "MemoryRootUpdateProposal", "SelfModelUpdateProposal", + "FalsificationFixtureProposal", # Weight profile factories "safe_weights", "conservative_weights", diff --git a/bench/results/prometheus-sigma-sweep-dryrun-2026-05-10.md b/bench/results/prometheus-sigma-sweep-dryrun-2026-05-10.md index 660f19e..18b313d 100644 --- a/bench/results/prometheus-sigma-sweep-dryrun-2026-05-10.md +++ b/bench/results/prometheus-sigma-sweep-dryrun-2026-05-10.md @@ -1,6 +1,6 @@ # Prometheus-Σ Phase 3 sleep-sweep dry-run -Generated: 2026-05-10T20:42:47Z UTC +Generated: 2026-05-10T20:54:12Z UTC Script: `bench/scripts/prometheus_sigma_sweep_dryrun.py` Ticket: #000037 Phase 3 (read-only simulation) @@ -14,16 +14,16 @@ Ticket: #000037 Phase 3 (read-only simulation) ## Target A — providence_cache sweep candidates -- Total candidates (rows older than τ_qa): **2379** -- Sweep chunks (size 4): 595 -- Controller runtime: 23.20 ms +- Total candidates (rows older than τ_qa): **2381** +- Sweep chunks (size 4): 596 +- Controller runtime: 51.93 ms ### Audit-mode distribution of candidates | audit_mode | count | |---|---| -| HYBRID | 872 | -| STRICT | 863 | +| HYBRID | 873 | +| STRICT | 864 | | UNGROUNDED | 635 | | CANONICAL_PROJECTION | 9 | @@ -32,7 +32,7 @@ Ticket: #000037 Phase 3 (read-only simulation) | label | chunks | |---|---| | DEFERRED | 322 | -| REJECT | 269 | +| REJECT | 270 | | MARGINAL | 4 | ### Veto kinds observed @@ -45,13 +45,14 @@ Ticket: #000037 Phase 3 (read-only simulation) - MemoryRoot proposals: 0 - SelfModel proposals: 0 -- Advisory event entries (would write to `controller_events` under Phase 2): 1141 +- FalsificationFixture proposals (§13 Step 11 — high-divergence → 5F-fixture funnel): **576** +- Advisory event entries (would write to `controller_events` under Phase 2): 1144 ## Target B — document sweep sample - Total sampled candidates: **2000** - Sweep chunks (size 4): 500 -- Controller runtime: 26.17 ms +- Controller runtime: 43.37 ms ### Canonical-shape regex prefilter hits @@ -85,9 +86,9 @@ Ticket: #000037 Phase 3 (read-only simulation) ## Total simulation cost -- Total branches scored: 4379 -- Total controller runtime: 49.37 ms -- Mean per-branch latency: 11.27 µs +- Total branches scored: 4381 +- Total controller runtime: 95.30 ms +- Mean per-branch latency: 21.75 µs ## Findings & fixes (dry-run iteration log) diff --git a/bench/scripts/prometheus_sigma_sweep_dryrun.py b/bench/scripts/prometheus_sigma_sweep_dryrun.py index 7421285..470f9a8 100644 --- a/bench/scripts/prometheus_sigma_sweep_dryrun.py +++ b/bench/scripts/prometheus_sigma_sweep_dryrun.py @@ -306,6 +306,7 @@ def sweep_target_a(shards_dir: Path, tau_qa_seconds: int, now: int, chunk_size: veto_kinds: Counter[str] = Counter() memory_proposals = 0 selfmodel_proposals = 0 + falsification_proposals = 0 advisory_event_count = 0 candidates_total = 0 chunks_total = 0 @@ -355,6 +356,7 @@ def sweep_target_a(shards_dir: Path, tau_qa_seconds: int, now: int, chunk_size: label_counts[decision.label] += 1 memory_proposals += len(decision.memory_proposals) selfmodel_proposals += len(decision.selfmodel_proposals) + falsification_proposals += len(decision.falsification_proposals) advisory_event_count += len(decision.advisory_events) for reasons in decision.veto_reasons.values(): for r in reasons: @@ -378,6 +380,7 @@ def sweep_target_a(shards_dir: Path, tau_qa_seconds: int, now: int, chunk_size: label_counts[decision.label] += 1 memory_proposals += len(decision.memory_proposals) selfmodel_proposals += len(decision.selfmodel_proposals) + falsification_proposals += len(decision.falsification_proposals) advisory_event_count += len(decision.advisory_events) for reasons in decision.veto_reasons.values(): for r in reasons: @@ -394,6 +397,7 @@ def sweep_target_a(shards_dir: Path, tau_qa_seconds: int, now: int, chunk_size: "veto_kinds": dict(veto_kinds), "memory_proposals_total": memory_proposals, "selfmodel_proposals_total": selfmodel_proposals, + "falsification_proposals_total": falsification_proposals, "advisory_event_count_total": advisory_event_count, "runtime_total_ms": round(runtime_total_ms, 2), "chunk_size": chunk_size, @@ -573,6 +577,9 @@ def render_markdown(a_results: dict, b_results: dict, opts: dict) -> str: f"{a_results['memory_proposals_total']}") lines.append(f"- SelfModel proposals: " f"{a_results['selfmodel_proposals_total']}") + lines.append(f"- FalsificationFixture proposals " + f"(§13 Step 11 — high-divergence → 5F-fixture funnel): " + f"**{a_results.get('falsification_proposals_total', 0)}**") lines.append(f"- Advisory event entries " f"(would write to `controller_events` under Phase 2): " f"{a_results['advisory_event_count_total']}") diff --git a/tests/test_prometheus.py b/tests/test_prometheus.py index 68202af..82cf9ab 100644 --- a/tests/test_prometheus.py +++ b/tests/test_prometheus.py @@ -606,3 +606,199 @@ def test_decision_is_a_controllerdecision(): 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