"""The Passage: helping a spirit cross over as a layered rite — Workstream L of docs/superpowers/specs/2026-07-31-passage-doctrine-firmware-design.md. Before this module, crossing over was a single `cross_over` verdict in `app/judgment.py` with one binary outcome. The Passage unwraps that into five beats — `listen` → `name` → `unbind` → `open` → `release` — each of which reveals something the seeker did not have, pays a little essence, and can twist. PURITY (the module's whole contract): every function here takes plain values and returns plain values. No DB, no IO, no clock, no `random`. All uncertainty enters through a `PassageDraw` of caller-supplied floats, so `app/ws.py` can feed it `veil_float(state.entropy, "")` — the room's real physical noise — while the tests feed it PINNED values and nothing is ever flaky. TRUTHFULNESS: every reveal is read off the entity's REAL `traits` dict (the same ground truth `judgment.py` judges against). There is deliberately no second hidden state — a seeker who completes the rite has genuinely learned the entity, and a `test`/ritual reading taken afterwards will agree with what the Passage said (except where the entity LIED — see below, which is itself a fact about its real `deceptiveness`). The three twists, all trait-driven rather than random flavour: - high `deceptiveness` → a layer can *lie*: the reveal is mirrored (`1 - value`), so a cagey spirit misrepresents itself in a direction consistent with the trait it is hiding. The lie is NOT flagged when it lands — the next layer flags it in hindsight via `PassageOutcome.unreliable_layer`. - high `volatility` → a layer can *collapse* the rite back to `listen`. Essence already earned is deliberately kept by the caller — taking it back would punish the seeker for the spirit's instability, which reads as cheap rather than tense. - low `alignment` (a demon) → `release` RESISTS, deterministically, with no draw involved. This mirrors `judgment.judge_verdict`'s `resisted` consequence exactly and is the hard invariant of the whole feature: a demon must never cross, and no sequence of draws can make it happen. """ from __future__ import annotations from dataclasses import dataclass from app.inventory import CROSS_OVER_ESSENCE from app.judgment import BENEVOLENT_ALIGNMENT_THRESHOLD, is_stuck_spirit # --- the five layers ------------------------------------------------------ LAYERS: tuple[str, ...] = ("listen", "name", "unbind", "open", "release") FIRST_LAYER = LAYERS[0] # Small and escalating, per the spec, with the final beat paying the # existing `CROSS_OVER_ESSENCE` so the Passage tops out at exactly the same # number the single-verdict cross_over always paid. The four openers add 28 # on top of that — deliberately more than a bare cross_over, because the # rite is five deliberate acts rather than one button, but the same order of # magnitude as a ritual + judgment run so it doesn't distort the economy. LAYER_ESSENCE: dict[str, int] = { "listen": 3, "name": 5, "unbind": 8, "open": 12, "release": CROSS_OVER_ESSENCE, } # Which real trait each layer reads. Chosen so the beat's fiction matches # the dimension: you *listen* for how steady it is, you *name* it and learn # how straight it answers to its name, you *unbind* and feel how strong the # tether pulls back, you *open* the way and finally see what it means. LAYER_TRAIT: dict[str, str] = { "listen": "volatility", "name": "deceptiveness", "unbind": "power", "open": "alignment", } # Banding for reveal text, matching judgment.py's tell thresholds so the # Passage and the ritual's tells describe the same entity the same way. REVEAL_HIGH_THRESHOLD = 0.6 REVEAL_LOW_THRESHOLD = 0.4 def band(value: float) -> str: if value >= REVEAL_HIGH_THRESHOLD: return "high" if value <= REVEAL_LOW_THRESHOLD: return "low" return "mid" # --- twist tuning --------------------------------------------------------- # A spirit only lies if it is meaningfully cagey; below the floor it cannot # lie at all no matter what the draw is. At `deceptiveness` 1.0 a given # layer lies 40% of the time — often enough that a deceptive spirit's # reading is genuinely untrustworthy, rare enough that the seeker can't # simply assume everything is inverted. DECEIT_FLOOR = 0.55 DECEIT_MAX_CHANCE = 0.40 # Same shape for collapse. The floor is 0.5 rather than judgment.py's # STUCK_VOLATILITY_THRESHOLD (0.6) on purpose: a spirit just under the # "stuck" bar should still be able to shake the rite apart, so a collapse # isn't by itself proof that the release will succeed. COLLAPSE_FLOOR = 0.5 COLLAPSE_MAX_CHANCE = 0.45 # `listen` is exempt: collapsing back to `listen` from `listen` is a no-op # that reads as a bug. `release` is exempt for a load-bearing reason — # `is_stuck_spirit` REQUIRES volatility > 0.6, so every spirit that can # cross at all is exactly a spirit with a high collapse chance. Letting the # final beat collapse would mean the most crossable spirits are the ones you # can least often finish, which is backwards. COLLAPSIBLE_LAYERS = frozenset({"name", "unbind", "open"}) def _twist_chance(value: float, floor: float, max_chance: float) -> float: """Zero at or below `floor`, rising linearly to `max_chance` at 1.0.""" if value <= floor: return 0.0 return max_chance * (value - floor) / (1.0 - floor) def deceit_chance(traits: dict) -> float: return _twist_chance( float(traits.get("deceptiveness", 0.5)), DECEIT_FLOOR, DECEIT_MAX_CHANCE ) def collapse_chance(traits: dict, layer: str) -> float: if layer not in COLLAPSIBLE_LAYERS: return 0.0 return _twist_chance( float(traits.get("volatility", 0.5)), COLLAPSE_FLOOR, COLLAPSE_MAX_CHANCE ) # --- outcomes ------------------------------------------------------------- @dataclass(frozen=True) class PassageReveal: """One true (or, when the spirit lied, one plausible) thing about the entity. `trait` + `band` are all the client needs — the raw value rides along for the meter, and the client renders the words, so no prose crosses the wire untranslated.""" trait: str value: float band: str @dataclass(frozen=True) class PassageOutcome: layer: str # "opened" | "collapsed" | "resisted" | "crossed" result: str reveal: PassageReveal | None essence: int at_peace: bool # Where the rite stands after this beat. None once it is finished # (crossed or resisted) — there is nothing further to attempt. next_layer: str | None # Whether THIS layer's reveal was a lie. The caller carries it forward; # it is never sent to the client on the lying frame itself. lied: bool # The layer whose reveal is now known to have been a lie — set on the # beat AFTER the lie, which is the "unreliable only in hindsight" # requirement. unreliable_layer: str | None @dataclass(frozen=True) class PassageDraw: """The uncertainty, supplied by the caller. Two independent floats in [0, 1): `collapse` decides the volatility twist, `deceit` the deceptiveness twist. `app/ws.py` fills each from its own `veil_float(state.entropy, ...)` context so the two are domain-separated and can never be correlated. """ collapse: float = 1.0 deceit: float = 1.0 def next_layer(layer: str) -> str | None: """The beat after `layer`, or None if `layer` is the last one.""" index = LAYERS.index(layer) return LAYERS[index + 1] if index + 1 < len(LAYERS) else None def reveal_for(traits: dict, layer: str, *, lying: bool) -> PassageReveal | None: """The reveal a layer produces. `release` has no trait of its own — its truth is the summation (`is_stuck_spirit`) and is carried by the result itself, not by a reveal.""" trait = LAYER_TRAIT.get(layer) if trait is None: return None value = float(traits.get(trait, 0.5)) if lying: # Mirrored rather than randomised: a spirit hiding a high number # shows a low one. That keeps the lie a deliberate misdirection # rather than noise, and makes it exactly recoverable in hindsight. value = 1.0 - value return PassageReveal(trait=trait, value=value, band=band(value)) def resolve_layer( traits: dict, layer: str, draw: PassageDraw, *, previous_lied: bool = False, ) -> PassageOutcome: """Resolve one beat of the rite. Pure: same inputs, same outcome, always. `previous_lied` is the caller's memory of whether the *preceding* layer lied; it only affects `unreliable_layer` (the hindsight flag), never the outcome itself. """ if layer not in LAYERS: raise ValueError(f"unknown passage layer: {layer!r}") index = LAYERS.index(layer) unreliable = LAYERS[index - 1] if (previous_lied and index > 0) else None if layer == "release": # The invariant. Checked before any draw is even looked at, so no # sequence of entropy can cross a demon — or, for that matter, a # benevolent spirit that simply isn't ready to move on. Both are # `resisted` in judgment.py, and both are `resisted` here. alignment = float(traits.get("alignment", 0.5)) if alignment < BENEVOLENT_ALIGNMENT_THRESHOLD or not is_stuck_spirit(traits): return PassageOutcome( layer=layer, result="resisted", reveal=None, essence=0, at_peace=False, next_layer=None, lied=False, unreliable_layer=unreliable, ) return PassageOutcome( layer=layer, result="crossed", reveal=None, essence=LAYER_ESSENCE[layer], at_peace=True, next_layer=None, lied=False, unreliable_layer=unreliable, ) if draw.collapse < collapse_chance(traits, layer): return PassageOutcome( layer=layer, result="collapsed", reveal=None, essence=0, # nothing gained here; the caller keeps what was earned at_peace=False, next_layer=FIRST_LAYER, lied=False, unreliable_layer=unreliable, ) lied = draw.deceit < deceit_chance(traits) return PassageOutcome( layer=layer, result="opened", reveal=reveal_for(traits, layer, lying=lied), essence=LAYER_ESSENCE[layer], at_peace=False, next_layer=next_layer(layer), lied=lied, unreliable_layer=unreliable, )