Files
qtalker---/backend/app/passage.py
Indiana 09089f01d5 feat: the Passage — crossing over becomes a layered rite (Workstream L)
cross_over was one verdict with one outcome. It is now five beats — listen,
name, unbind, open, release — each revealing something true about the
spirit, each paying escalating essence, each able to twist.

Reveals use the entity's REAL traits; there is no second hidden state
invented for the rite. Twists are trait-driven, verified directly rather
than assumed: a demon collapses the rite 3.6x more often than a calm spirit
(0.360 vs 0.099 at `unbind`) and lies ~31% of the time, while a spirit
under DECEIT_FLOOR cannot lie on any draw. A demon still resists at
`release` and never crosses — the existing judgment rule is preserved, not
re-implemented. Every draw comes from veil_float on the room's physical
entropy, never `random`.

Essence is kept across a collapse. Clawing it back would punish a seeker
for the spirit's instability, which is not theirs to control.

FIXED AN INFINITE LOOP IN THE SALVAGED TESTS, not a flake:
test_full_rite_on_a_calm_stuck_spirit ran `while layer is not None` while
passing collapse=FIRES. Its fixture comment claimed "both twist chances are
0 at these values, so NO draw can make this entity lie or collapse" — that
is false. COLLAPSE_FLOOR is 0.5 (deliberately below judgment's stuck bar of
0.6, as passage.py explains), and the fixture's volatility is 0.61, giving a
real ~9.9% collapse chance. Forced to fire, every layer bounced back to
`listen` and the run hung forever instead of failing.

Three fixes: hold the collapse draw (deceit still fires, which is the
point — it proves a spirit under the floor cannot lie even when told to),
correct the false comment, and bound the loop so a future regression fails
in seconds rather than hanging a test run.

Also added the entire seance.passage i18n block in both languages — the
agent died before writing it, so the gate was failing on 35 missing keys —
and reworded a comment in PassagePanel that spelled out a translation call
in full: the coverage checker greps source text and cannot tell a comment
from real code, so it demanded a key for the placeholder.

34 passage tests pass; 385 frontend; i18n parity and typecheck clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 14:19:55 +00:00

280 lines
10 KiB
Python

"""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, "<context>")` — 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,
)