"""Prometheus-Σ recursive falsification controller (ticket #000037, Phase 1).

Pure-function advisory controller over already-committed substrate state.
Implements §5 (Shannon-entropy fork-selection with numerically stable
softmax), §6 (eight-class hard-veto order), §7 (Kelly-bounded
allocation with four safety guards), §7.1 (EMA-smoothed difficulty
update law), §13 algorithm steps 1-9 + 12, §14 exception-handling
matrix, and §15 three named weight profiles (safe / conservative /
exploratory).

Phase scope (locked):

- **Phase 1 (this module):** pure function. No DB writes, no LLM
  calls, no scheduler, no background jobs. Controller returns a
  :class:`ControllerDecision` (advisory) plus optional
  :class:`MemoryRootUpdateProposal` / :class:`SelfModelUpdateProposal`
  records — proposals, never mutations. The hard rule from §4.4
  stands: controller never mutates MemoryRoot or SelfModel directly.
- **Phase 2 (separate ticket scope):** sibling ``controller_events``
  audit-row writes. This module builds the ``advisory_events`` tuple
  the writer will consume; it does not persist anything.
- **Phase 3 (separate ticket scope):** unconscious sweep scheduler
  that walks ``providence_cache`` and ``documents`` during ingest
  lulls. Out of scope here.

Doctrinal commitments (preserved verbatim from §21 of the ticket):

- The controller does not defeat Gödel.
- LLM is witness, never authority.

This module imports nothing from :mod:`arborist.qa.client` (LLM
gateway) or :mod:`arborist.qa.keys` (cache_key path) — verified by
:func:`test_no_llm_call_in_controller` and
:func:`test_controller_does_not_modify_cache_key_inputs`.

§8 note — cross-chain matrix omission. The §13 algorithm references
the §8 ``A[i, j]`` cross-chain matrix; the spec is explicit that
Phase 1 uses only the scalar utility ``U_i`` (§5) and softmax. The
full ``A[i, j]`` matrix is **deferred to Phase 2+ if expressivity is
insufficient** — the scalar form already passes the §16.2 test list
and the matrix adds complexity that the ticket explicitly gates on
measured pressure.
"""

from __future__ import annotations

import math
from dataclasses import dataclass, field, replace
from typing import Literal, Sequence


# ---------------------------------------------------------------------
# Veto classification (§6)
# ---------------------------------------------------------------------

#: §6 veto-tag → output-label class. ``hard_vetoes`` on a
#: ControllerBranch is the input signal — caller pre-classifies. The
#: controller routes by tag string to the right output label class.
VETO_CLASS: dict[str, Literal["QUARANTINE", "REJECT", "ESCALATE"]] = {
    # §6.1, §6.4, §6.5, §6.7 — QUARANTINE class
    "unsupported_carrier": "QUARANTINE",
    "cache_drift": "QUARANTINE",
    "soft_hash_signal": "QUARANTINE",
    "schema_mismatch": "QUARANTINE",
    # §6.2, §6.3, §6.8 — REJECT class
    "verifier_failure": "REJECT",
    "hard_regression": "REJECT",
    "source_warrant_downgrade": "REJECT",
    # §6.6 + soft-hash replay-window — ESCALATE class
    "memory_invalidation_exceeded": "ESCALATE",
    "replay_window_unbounded": "ESCALATE",
}

#: Severity ordering when ALL branches are vetoed and the controller
#: must pick the most severe class present. Fail-loud — ESCALATE >
#: QUARANTINE > REJECT.
_SEVERITY_RANK: dict[str, int] = {
    "ESCALATE": 3,
    "QUARANTINE": 2,
    "REJECT": 1,
}

#: Entropy gating thresholds (§5). ``H_norm <= H_LOW`` → narrow,
#: confident; ``H_norm >= H_HIGH`` → widen, uncertain.
H_LOW = 0.3
H_HIGH = 0.7

#: §9 memory-invalidation governance threshold (κ). A selected branch
#: with ``memory_invalidation >= KAPPA_MEMORY`` escalates rather than
#: silently committing across a large memory frontier. Default per the
#: ticket text where ``κ`` appears in §6 / §8 / §14 without a numeric
#: value — fox can tighten per profile later.
KAPPA_MEMORY = 0.5

#: §12 Trigger 2 minimum sample count.
N_MIN_TRIGGER_2 = 30

#: §12 Trigger 2 coefficient-of-variation threshold (also matches the
#: docstring text "CoV ≥ 0.5").
COV_THRESHOLD_TRIGGER_2 = 0.5

#: §12 Trigger 2 absolute stddev threshold for the zero-mean fallback
#: path. Picked to match the spec text "≥ 0.05" (smaller than the
#: 0.10 absolute-stddev cutoff for the normal CoV variant — the
#: zero-mean path is the "any signal at all" path).
ABS_STDDEV_ZERO_MEAN = 0.05


# ---------------------------------------------------------------------
# Locked dataclass contract (§16.1)
# ---------------------------------------------------------------------


@dataclass(frozen=True)
class BatteryDeltas:
    """Per-branch Δ-rate against the 5S/5T/5F/5R batteries (§5)."""

    delta_5s: float
    delta_5t: float
    delta_5f: float
    delta_5r: float


@dataclass(frozen=True)
class ControllerBranch:
    """One candidate branch fed into the controller.

    ``hard_vetoes`` carries pre-classified veto tags from upstream
    (kernel, verifier, soft-hash analyzer, …). The controller does
    not re-classify; it routes per :data:`VETO_CLASS`.

    ``payoff_b`` is the Kelly edge-payoff (§7). Defaults to 1.0
    (even bet) so callers that haven't measured payoff don't need
    to set it. Tests exercise the ``b_i <= 0`` safety guard via
    this field.

    **Cost-class split (Phase 1.c)** — ``kernel_cost`` and
    ``llm_cost`` decompose what was previously a single
    ``capital_cost`` field. Per dry-run Finding #2, real sweep work
    spans an order-of-magnitude cost spread (kernel-only re-probe
    ≈ 0.02-0.05, full LLM witness ≈ 1.0). Splitting them lets the
    controller's utility formula reflect what the witness path
    actually spends. Back-compat: when both new fields are 0 the
    controller falls back to the legacy ``capital_cost``; existing
    callers don't have to migrate. Tests pin both paths.
    """

    branch_id: str
    deltas: BatteryDeltas
    witness_divergence: float
    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
    # Phase 1.c cost-class split. Defaults to 0 → back-compat
    # mode (use legacy ``capital_cost``).
    kernel_cost: float = 0.0
    llm_cost: float = 0.0

    @property
    def effective_cost(self) -> float:
        """The cost value the controller spends in the utility formula.

        Returns ``kernel_cost + llm_cost`` when either is positive;
        otherwise returns the legacy ``capital_cost`` (back-compat
        path for pre-Phase-1.c callers).
        """
        split = self.kernel_cost + self.llm_cost
        return split if split > 0.0 else self.capital_cost


@dataclass(frozen=True)
class ControllerWeights:
    """§15.1 safe-default weight profile.

    Other profiles via :func:`conservative_weights` /
    :func:`exploratory_weights`. The profile name does NOT enter
    ``governance_policy_hash`` in Phase 1 (§15 explicit).
    """

    # §5 utility coefficients
    alpha_5s: float = 1.0
    beta_5t: float = 1.0
    gamma_5f: float = 1.25
    rho_5r: float = 1.0
    sigma_selfmodel_calibration: float = 0.75
    tau_warrant_promotion: float = 0.75
    lambda_capital_cost: float = 1.0
    mu_regression_penalty: float = 2.0
    nu_witness_divergence: float = 1.5
    xi_security_risk: float = 3.0
    omega_memory_invalidation: float = 2.0

    # §5 softmax temperature + §7.1 EMA smoothing
    eta_softmax_temperature: float = 1.0
    ema_smoothing_r: float = 0.2

    # §7.1 difficulty-update sub-weights
    a_entropy_drive: float = 0.1
    b_divergence_drive: float = 0.1
    c_hard_regression_drive: float = 0.3
    d_witness_agreement_pull: float = 0.1
    e_capital_pressure_pull: float = 0.05
    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:
    """Full input bundle for one controller invocation.

    ``budget`` is the binding Hermes-concurrency constraint (~4 in
    today's deployment, per §11). ``hermes_utilization`` is the
    current in-flight count — Phase 2 cooperative-yield logic will
    consult it. Phase 1 honors only ``budget == 0`` (§14 row).
    """

    organism_root: str
    branches: tuple[ControllerBranch, ...]
    budget: int
    hermes_utilization: int
    weights: ControllerWeights
    difficulty: float
    divergence_rate: float = 0.0
    hard_regression_rate: float = 0.0
    witness_agreement_rate: float = 1.0
    capital_pressure: float = 0.0


ControllerLabel = Literal[
    "ACCEPT",
    "MARGINAL",
    "REJECT",
    "QUARANTINE",
    "UNKNOWN",
    "ESCALATE",
    "DEFERRED",
]


@dataclass(frozen=True)
class MemoryRootUpdateProposal:
    """§4.4 — proposal record for MemoryRoot. Phase 2 commits."""

    organism_root: str
    branch_id: str
    proposed_invalidation_tags: tuple[str, ...]


@dataclass(frozen=True)
class SelfModelUpdateProposal:
    """§4.4 — proposal record for SelfModel. Phase 2 commits."""

    organism_root: str
    branch_id: str
    calibration_delta: float
    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``."""

    selected_branch_id: str | None
    label: ControllerLabel
    allocations: dict[str, float]
    difficulty_next: float
    veto_reasons: dict[str, tuple[str, ...]]
    entropy: float
    notes: tuple[str, ...]
    memory_proposals: tuple[MemoryRootUpdateProposal, ...] = ()
    selfmodel_proposals: tuple[SelfModelUpdateProposal, ...] = ()
    falsification_proposals: tuple[FalsificationFixtureProposal, ...] = ()
    advisory_events: tuple[tuple[str, dict], ...] = ()


# ---------------------------------------------------------------------
# §15 weight-profile factories
# ---------------------------------------------------------------------


def safe_weights() -> ControllerWeights:
    """§15.1 safe-default profile — Phase 1 baseline."""
    return ControllerWeights()


def conservative_weights() -> ControllerWeights:
    """§15.2 conservative profile — real-shard production."""
    return replace(
        ControllerWeights(),
        gamma_5f=1.0,
        lambda_capital_cost=1.5,
        xi_security_risk=4.0,
        eta_softmax_temperature=0.75,
    )


def exploratory_weights() -> ControllerWeights:
    """§15.3 exploratory profile — research sweeps."""
    return replace(
        ControllerWeights(),
        gamma_5f=1.5,
        nu_witness_divergence=0.75,
        lambda_capital_cost=0.5,
        eta_softmax_temperature=1.5,
    )


def sweep_weights() -> ControllerWeights:
    """Sleep-sweep profile (Phase 1.c — dry-run Finding #3).

    Tuned for Phase 3 sleep-sweep economics: sweep work deliberately
    accepts capital cost in exchange for falsification discovery, so
    ``lambda_capital_cost`` drops to 0.25 (vs safe-default 1.0) and
    ``gamma_5f`` bumps to 1.5 (vs 1.25) to reward falsification-
    rate improvement. Witness divergence weight drops to 0.5 (vs
    1.5) because high divergence is *desirable* signal during sweep
    — it's what surfaces fixture candidates (§13 step 11).
    """
    return replace(
        ControllerWeights(),
        gamma_5f=1.5,
        lambda_capital_cost=0.25,
        nu_witness_divergence=0.5,
    )


WEIGHT_PROFILES: dict[str, ControllerWeights] = {
    "safe": safe_weights(),
    "conservative": conservative_weights(),
    "exploratory": exploratory_weights(),
    "sweep": sweep_weights(),
}


# ---------------------------------------------------------------------
# §12 Trigger 2 — divergence-variance phase-gate check
# ---------------------------------------------------------------------


def phase_1_trigger_check(
    divergence_samples: Sequence[float],
) -> tuple[bool, str]:
    """§12 Trigger 2 — divergence-variance check with §14 guards.

    Returns ``(triggered, reason)``. Edge cases per §14:

    - sample_count < :data:`N_MIN_TRIGGER_2` → ``(False, "small_sample")``
    - mean == 0:
      - stddev == 0 → ``(False, "no_variance_signal")``
      - stddev > 0 → ``(stddev >= ABS_STDDEV_ZERO_MEAN, "absolute_stddev_path")``
    - otherwise CoV = stddev / max(mean, 1e-6):
      - ``CoV >= COV_THRESHOLD_TRIGGER_2`` → ``(True, "variance_threshold")``
      - else → ``(False, "variance_below_threshold")``
    """
    n = len(divergence_samples)
    if n < N_MIN_TRIGGER_2:
        return (False, "small_sample")

    mean = sum(divergence_samples) / n
    # Population stddev — the controller is making a decision over the
    # observed window, not estimating a parent population parameter.
    var = sum((x - mean) ** 2 for x in divergence_samples) / n
    stddev = math.sqrt(var)

    if mean == 0.0:
        if stddev == 0.0:
            return (False, "no_variance_signal")
        return (stddev >= ABS_STDDEV_ZERO_MEAN, "absolute_stddev_path")

    cov = stddev / max(mean, 1e-6)
    if cov >= COV_THRESHOLD_TRIGGER_2:
        return (True, "variance_threshold")
    return (False, "variance_below_threshold")


# ---------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------


def _utility(branch: ControllerBranch, w: ControllerWeights) -> float:
    """§5 scalar utility ``U_i`` — all 11 weighted terms.

    Uses :attr:`ControllerBranch.effective_cost` so the Phase 1.c
    kernel/llm split and the legacy capital_cost path produce
    byte-identical utility values when only one path is populated.
    """
    d = branch.deltas
    return (
        w.alpha_5s * d.delta_5s
        + w.beta_5t * d.delta_5t
        + w.gamma_5f * d.delta_5f
        + w.rho_5r * d.delta_5r
        + w.sigma_selfmodel_calibration * branch.selfmodel_calibration_gain
        + w.tau_warrant_promotion * branch.warrant_promotion_gain
        - w.lambda_capital_cost * branch.effective_cost
        - w.mu_regression_penalty * branch.regression_penalty
        - w.nu_witness_divergence * branch.witness_divergence
        - w.xi_security_risk * branch.security_risk
        - w.omega_memory_invalidation * branch.memory_invalidation
    )


def _stable_softmax(zs: list[float]) -> list[float]:
    """§5 numerically stable softmax: ``exp(z - max z) / Σ exp(z - max z)``.

    Empty input yields empty output. All-equal input yields uniform.
    Required — naive ``exp(η · U_i)`` overflows around ``η · U > ~700``.
    """
    if not zs:
        return []
    m = max(zs)
    exps = [math.exp(z - m) for z in zs]
    total = sum(exps)
    if total == 0.0:  # defensive — shouldn't happen post-shift
        n = len(zs)
        return [1.0 / n] * n
    return [e / total for e in exps]


def _entropy_norm(ps: list[float]) -> float:
    """§5 normalized Shannon entropy ``H(p) / log(n)``.

    Edge case: ``n == 1`` → ``log(1) = 0`` would divide by zero;
    return ``0.0`` (the distribution is a point mass, zero entropy
    by construction).
    """
    n = len(ps)
    if n <= 1:
        return 0.0
    h = 0.0
    for p in ps:
        if p > 0.0:
            h -= p * math.log(p)
    return h / math.log(n)


def _all_vetoed_label(
    branches: Sequence[ControllerBranch],
) -> tuple[ControllerLabel, str]:
    """§3 / §6 — pick most severe veto class across all branches.

    Returns ``(label, severity_class)``. Fail-loud: ESCALATE >
    QUARANTINE > REJECT.
    """
    worst_rank = 0
    worst_class: str = "REJECT"
    for b in branches:
        for tag in b.hard_vetoes:
            cls = VETO_CLASS.get(tag, "REJECT")
            r = _SEVERITY_RANK.get(cls, 0)
            if r > worst_rank:
                worst_rank = r
                worst_class = cls
    # worst_class is already a ControllerLabel (REJECT / QUARANTINE /
    # ESCALATE), all three valid output labels.
    return (worst_class, worst_class)  # type: ignore[return-value]


def _clamp(x: float, lo: float, hi: float) -> float:
    return max(lo, min(hi, x))


def _difficulty_next(
    inp: ControllerInput,
    h_norm: float,
) -> float:
    """§7.1 EMA-smoothed difficulty update law."""
    w = inp.weights
    raw = _clamp(
        inp.difficulty
        + w.a_entropy_drive * h_norm
        + w.b_divergence_drive * inp.divergence_rate
        + w.c_hard_regression_drive * inp.hard_regression_rate
        - w.d_witness_agreement_pull * inp.witness_agreement_rate
        - w.e_capital_pressure_pull * inp.capital_pressure,
        w.min_difficulty,
        w.max_difficulty,
    )
    r = w.ema_smoothing_r
    return (1.0 - r) * inp.difficulty + r * raw


# ---------------------------------------------------------------------
# Algorithm entry point (§13)
# ---------------------------------------------------------------------


def controller_decide(inp: ControllerInput) -> ControllerDecision:
    """Run the §13 algorithm (steps 1-9 + 12) on the input bundle.

    Pure function: no DB, no LLM, no scheduler. Same inputs →
    byte-identical output. Idempotent.

    Steps:

    1. Accept pre-computed ``hard_vetoes`` per branch.
    2. (Phase 1 scalar utility only — full ``A[i, j]`` matrix per
       §8 deferred to Phase 2+ if expressivity insufficient.)
    3. Apply §6 hard-veto order; ALL-vetoed cascades to the most
       severe class present (ESCALATE > QUARANTINE > REJECT).
    4. Compute ``U_i`` (§5) for unvetoed branches.
    5. Stable softmax ``p_i = exp(z_i - max z) / Σ exp(z_j - max z)``.
    6. ``H_norm = H(p) / log(n)`` (0.0 when n=1).
    7. EMA-smoothed difficulty update (§7.1).
    8. Kelly-bounded allocation with four guards (§7): vetoed,
       ``payoff_b <= 0``, ``p_i <= 0``, ``Σ_raw == 0``.
    9. Label dispatch per §14 exception matrix.
    12. Emit MemoryRoot / SelfModel update **proposals** — never
        mutations. The existing write paths gate every commit on
        their own validation; the controller is advisory only.
    """
    branches = inp.branches
    w = inp.weights
    veto_reasons: dict[str, tuple[str, ...]] = {
        b.branch_id: tuple(b.hard_vetoes) for b in branches if b.hard_vetoes
    }
    notes: list[str] = []

    # ---- §14: no candidate branches → UNKNOWN ----
    if not branches:
        return ControllerDecision(
            selected_branch_id=None,
            label="UNKNOWN",
            allocations={},
            difficulty_next=inp.difficulty,
            veto_reasons={},
            entropy=0.0,
            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:
        label, _ = _all_vetoed_label(branches)
        # §14: ALL_BRANCHES_VETOED → no allocation, no proposals.
        # (Memory-proposal emission below filters on vetoed-AND-positive-
        # regression-penalty, so cascade still passes through it.)
        memory_proposals = _emit_memory_proposals(inp.organism_root, branches)
        # Difficulty still updates — entropy is 0 (no live distribution).
        difficulty_next = _difficulty_next(inp, h_norm=0.0)
        advisory_events = (
            (
                "controller_decision",
                {
                    "organism_root": inp.organism_root,
                    "label": label,
                    "reason": "ALL_BRANCHES_VETOED",
                    "selected_branch_id": None,
                    "veto_count": len(branches),
                },
            ),
        )
        return ControllerDecision(
            selected_branch_id=None,
            label=label,
            allocations={b.branch_id: 0.0 for b in branches},
            difficulty_next=difficulty_next,
            veto_reasons=veto_reasons,
            entropy=0.0,
            notes=("ALL_BRANCHES_VETOED",),
            memory_proposals=memory_proposals,
            falsification_proposals=falsification_proposals,
            advisory_events=advisory_events,
        )

    # ---- Step 4: utilities ----
    utilities = {b.branch_id: _utility(b, w) for b in unvetoed}

    # ---- Step 5: stable softmax ----
    eta = w.eta_softmax_temperature
    zs = [eta * utilities[b.branch_id] for b in unvetoed]
    ps_unvetoed = _stable_softmax(zs)
    probs: dict[str, float] = {
        b.branch_id: p for b, p in zip(unvetoed, ps_unvetoed)
    }

    # ---- Step 6: normalized entropy ----
    h_norm = _entropy_norm(ps_unvetoed)

    # ---- Step 7: difficulty update ----
    difficulty_next = _difficulty_next(inp, h_norm)

    # ---- 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 = (
            (
                "controller_decision",
                {
                    "organism_root": inp.organism_root,
                    "label": "DEFERRED",
                    "reason": "ZERO_BUDGET",
                    "entropy": h_norm,
                },
            ),
        )
        return ControllerDecision(
            selected_branch_id=None,
            label="DEFERRED",
            allocations=allocations,
            difficulty_next=difficulty_next,
            veto_reasons=veto_reasons,
            entropy=h_norm,
            notes=("ZERO_BUDGET",),
            falsification_proposals=falsification_proposals,
            advisory_events=advisory_events,
        )

    raw: dict[str, float] = {}
    for b in unvetoed:
        p_i = probs[b.branch_id]
        b_i = b.payoff_b
        if b_i <= 0.0:
            f_i = 0.0
        elif p_i <= 0.0:
            f_i = 0.0
        else:
            q_i = 1.0 - p_i
            f_i = max(0.0, (p_i * b_i - q_i) / b_i)
        # raw_i = f_i · exp(η · U_i) using the SAME η. Re-stabilize
        # using the same max-shift so the multiplication doesn't
        # overflow even if U_i is large.
        z_i = eta * utilities[b.branch_id]
        # exp(z_i - max(zs)) is in [0, 1] post-shift — bounded.
        if zs:
            max_z = max(zs)
            scaled_exp = math.exp(z_i - max_z)
        else:
            scaled_exp = 1.0
        raw[b.branch_id] = f_i * scaled_exp

    sum_raw = sum(raw.values())

    # §14 + algorithm step 8: Σ_raw == 0 → DEFERRED.
    if sum_raw == 0.0:
        advisory_events = (
            (
                "controller_decision",
                {
                    "organism_root": inp.organism_root,
                    "label": "DEFERRED",
                    "reason": "ZERO_RAW_ALLOCATION",
                    "entropy": h_norm,
                },
            ),
        )
        return ControllerDecision(
            selected_branch_id=None,
            label="DEFERRED",
            allocations=allocations,
            difficulty_next=difficulty_next,
            veto_reasons=veto_reasons,
            entropy=h_norm,
            notes=("ZERO_RAW_ALLOCATION",),
            falsification_proposals=falsification_proposals,
            advisory_events=advisory_events,
        )

    # Defensive: NaN / inf check on sum_raw (should never trigger
    # given the stable-softmax max-shift, but the spec calls for the
    # UNKNOWN exit if the math comes out invalid).
    if math.isnan(sum_raw) or math.isinf(sum_raw):
        advisory_events = (
            (
                "controller_decision",
                {
                    "organism_root": inp.organism_root,
                    "label": "UNKNOWN",
                    "reason": "NUMERIC_INSTABILITY",
                    "entropy": h_norm,
                },
            ),
        )
        return ControllerDecision(
            selected_branch_id=None,
            label="UNKNOWN",
            allocations=allocations,
            difficulty_next=difficulty_next,
            veto_reasons=veto_reasons,
            entropy=h_norm,
            notes=("NUMERIC_INSTABILITY",),
            falsification_proposals=falsification_proposals,
            advisory_events=advisory_events,
        )

    budget_f = float(inp.budget)
    for bid, r_i in raw.items():
        allocations[bid] = budget_f * r_i / sum_raw

    # ---- Step 9: label dispatch (§14 exception matrix) ----
    # Pick the branch with highest allocation among unvetoed.
    selected = max(
        unvetoed, key=lambda b: (allocations[b.branch_id], b.branch_id)
    )
    selected_u = utilities[selected.branch_id]

    label: ControllerLabel
    reason_notes: list[str] = []

    if selected.payoff_b <= 0.0:
        label = "REJECT"
        reason_notes.append("NEGATIVE_PAYOFF")
    elif selected_u <= 0.0:
        label = "REJECT"
        reason_notes.append("NON_POSITIVE_UTILITY")
    elif selected.memory_invalidation >= w.kappa_memory:
        label = "ESCALATE"
        reason_notes.append("MEMORY_INVALIDATION_ABOVE_KAPPA")
    elif h_norm <= w.h_low:
        label = "ACCEPT"
        reason_notes.append("LOW_ENTROPY")
    elif h_norm >= w.h_high:
        label = "MARGINAL"
        reason_notes.append("HIGH_ENTROPY")
    else:
        label = "ACCEPT"
        reason_notes.append("MID_ENTROPY")

    notes.extend(reason_notes)

    # ---- Step 12: emit proposals (NOT mutations) ----
    memory_proposals = _emit_memory_proposals(inp.organism_root, branches)
    selfmodel_proposals: tuple[SelfModelUpdateProposal, ...] = ()
    if selected.selfmodel_calibration_gain != 0.0:
        selfmodel_proposals = (
            SelfModelUpdateProposal(
                organism_root=inp.organism_root,
                branch_id=selected.branch_id,
                calibration_delta=selected.selfmodel_calibration_gain,
                reason="controller_phase_1_calibration",
            ),
        )

    # 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], ...] = (
        (
            "controller_decision",
            {
                "organism_root": inp.organism_root,
                "label": label,
                "selected_branch_id": selected.branch_id,
                "entropy": h_norm,
                "utility": selected_u,
                "allocation": allocations[selected.branch_id],
                "reason": reason_notes[0] if reason_notes else "",
            },
        ),
        (
            "controller_difficulty",
            {
                "organism_root": inp.organism_root,
                "difficulty_prev": inp.difficulty,
                "difficulty_next": difficulty_next,
                "h_norm": h_norm,
                "divergence_rate": inp.divergence_rate,
            },
        ),
        (
            "controller_budget_allocation",
            {
                "organism_root": inp.organism_root,
                "budget": inp.budget,
                "allocations": dict(allocations),
            },
        ),
    )

    return ControllerDecision(
        selected_branch_id=selected.branch_id,
        label=label,
        allocations=allocations,
        difficulty_next=difficulty_next,
        veto_reasons=veto_reasons,
        entropy=h_norm,
        notes=tuple(notes),
        memory_proposals=memory_proposals,
        selfmodel_proposals=selfmodel_proposals,
        falsification_proposals=falsification_proposals,
        advisory_events=advisory_events,
    )


def _emit_memory_proposals(
    organism_root: str,
    branches: Sequence[ControllerBranch],
) -> tuple[MemoryRootUpdateProposal, ...]:
    """Step 12 — propose ``BRANCH_REGRESSED`` invalidation tags.

    Triggers when a branch has ``regression_penalty > 0`` AND it was
    vetoed (i.e. carries any hard veto). Pure proposal — no mutation.
    """
    proposals: list[MemoryRootUpdateProposal] = []
    for b in branches:
        if b.regression_penalty > 0.0 and b.hard_vetoes:
            proposals.append(
                MemoryRootUpdateProposal(
                    organism_root=organism_root,
                    branch_id=b.branch_id,
                    proposed_invalidation_tags=("BRANCH_REGRESSED",),
                )
            )
    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",
    "H_LOW",
    "H_HIGH",
    "KAPPA_MEMORY",
    "N_MIN_TRIGGER_2",
    "COV_THRESHOLD_TRIGGER_2",
    "ABS_STDDEV_ZERO_MEAN",
    "WEIGHT_PROFILES",
    # Dataclasses
    "BatteryDeltas",
    "ControllerBranch",
    "ControllerWeights",
    "ControllerInput",
    "ControllerDecision",
    "MemoryRootUpdateProposal",
    "SelfModelUpdateProposal",
    "FalsificationFixtureProposal",
    # Weight profile factories
    "safe_weights",
    "conservative_weights",
    "exploratory_weights",
    "sweep_weights",
    # Entry points
    "controller_decide",
    "phase_1_trigger_check",
]
