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>
This commit is contained in:
Indiana
2026-07-31 14:19:55 +00:00
parent 0966fa8cfc
commit 09089f01d5
10 changed files with 1580 additions and 1 deletions

279
backend/app/passage.py Normal file
View File

@@ -0,0 +1,279 @@
"""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,
)

View File

@@ -12,6 +12,9 @@ Protocol (client → server):
{"type": "ritual_step", "step": <int>} → (on the final step) ritual_complete
{"type": "judgment", "verdict": "trust" | "banish" | "test" | "cross_over"}
→ judgment_result
{"type": "passage_start"} → passage_state (the rite begins)
{"type": "passage_layer"} → passage_result (one beat of the
layered crossing rite: listen → name → unbind → open → release)
{"type": "scry", "image": "<base64 jpeg>"} → {"type": "utterance", kind: "scry"}
The seeker's camera, shown to the vision model so the entity can speak
about the real room. The frame is never stored or logged — only the
@@ -35,7 +38,7 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from app import judgment
from app import judgment, passage
from app.config import settings
from app.db import async_session_maker as _default_session_maker
from app.deps import SESSION_COOKIE_NAME
@@ -100,6 +103,13 @@ question_ip_limiter = RateLimiter(max_requests=12, window_seconds=60)
summon_ip_limiter = RateLimiter(max_requests=8, window_seconds=60)
ritual_ip_limiter = RateLimiter(max_requests=12, window_seconds=60)
judgment_ip_limiter = RateLimiter(max_requests=20, window_seconds=60)
# The Passage (Workstream L): each `passage_layer` frame is its own essence
# credit, so it needs the same bounding as ritual/judgment. The budget is
# larger because one rite is five frames and a collapse legitimately makes a
# seeker replay earlier layers — four full rites a minute is already far
# past human pace, but never trips on honest play.
passage_limiter = RateLimiter(max_requests=20, window_seconds=60)
passage_ip_limiter = RateLimiter(max_requests=40, window_seconds=60)
scry_ip_limiter = RateLimiter(max_requests=8, window_seconds=60)
# Probability that a channel's familiar presence answers again rather than
@@ -143,6 +153,16 @@ class SeanceState:
ritual_steps: int = 0
ritual_completed: bool = False
ritual_success: bool = False
# Workstream L (passage-doctrine spec): where the layered crossing rite
# stands for the *current* entity. `passage_layer` is the next beat to
# attempt, `passage_lied` remembers whether the layer just completed was
# a lie (so the next one can flag it in hindsight), and `passage_crossed`
# latches once the spirit has actually crossed so the rite — and the
# cross_over verdict — can't be replayed for a second payout. All three
# reset on a fresh summon, like the ritual fields above.
passage_layer: str = passage.FIRST_LAYER
passage_lied: bool = False
passage_crossed: bool = False
# Per-session RNG for `tell` frames — unseeded (a session's tells should
# vary run to run), but persistent across calls so the draw sequence
# isn't restarted on every single message.
@@ -552,6 +572,7 @@ async def _summon_locked(state: SeanceState) -> None:
state.ritual_steps = 0
state.ritual_completed = False
state.ritual_success = False
_reset_passage(state)
await state.send_queue.put(
{"type": "entity", "entity": _public_entity(state.entity), "is_new": is_new}
)
@@ -887,6 +908,12 @@ async def _handle_judgment(state: SeanceState, message: dict) -> None:
)
return
# The Passage already crossed this spirit and already paid for it — a
# `cross_over` verdict on top would credit CROSS_OVER_ESSENCE a second
# time for the same crossing. One spirit, one passage, one payment.
if verdict == "cross_over" and state.passage_crossed:
return
traits = state.entity.get("traits", {})
outcome = judgment.judge_verdict(
verdict,
@@ -945,6 +972,147 @@ async def _handle_judgment(state: SeanceState, message: dict) -> None:
await state.send_queue.put({"type": "item_drop", "item": item})
# --- Workstream L: the Passage (passage-doctrine spec) ---------------------
#
# Five beats — listen / name / unbind / open / release — each revealing one
# real trait, paying a little essence, and able to twist. All of the actual
# logic is in `app/passage.py`, which is pure; this handler owns only the
# things a pure module can't: the limiter, the two entropy draws, the
# essence credit (via the same `credit_essence` the rest of the economy
# uses), the `at_peace` write, and the frames.
def _reset_passage(state: SeanceState) -> None:
state.passage_layer = passage.FIRST_LAYER
state.passage_lied = False
state.passage_crossed = False
def _passage_frame(state: SeanceState, outcome: passage.PassageOutcome) -> dict:
reveal = outcome.reveal
return {
"type": "passage_result",
"layer": outcome.layer,
"result": outcome.result,
# The client renders the words from (trait, band) via i18n, so no
# untranslated prose ever crosses the wire.
"reveal": (
{"trait": reveal.trait, "value": reveal.value, "band": reveal.band}
if reveal is not None
else None
),
"essence": outcome.essence,
"at_peace": outcome.at_peace,
"next_layer": outcome.next_layer,
# Deliberately NOT `outcome.lied` — that's this layer's own lie, and
# the seeker must not learn of it until the next beat. This field is
# the *previous* layer's lie, surfacing in hindsight.
"unreliable_layer": outcome.unreliable_layer,
"sealed": state.passage_crossed,
}
async def _handle_passage_start(state: SeanceState) -> None:
if state.entity is None:
return # no presence to pass — the frontend already gates the button
if state.passage_crossed:
return # already at peace; there is nothing left to walk
if not (
passage_limiter.allow(str(state.user_id))
and passage_ip_limiter.allow(state.client_ip)
):
await state.send_queue.put(
{
"type": "error",
"code": "rate_limited",
"message": "The way is still closing behind you. Give it a moment.",
}
)
return
_reset_passage(state)
await state.send_queue.put(
{"type": "passage_state", "layer": state.passage_layer, "sealed": False}
)
async def _handle_passage_layer(state: SeanceState) -> None:
if state.entity is None or state.passage_crossed:
return
if not (
passage_limiter.allow(str(state.user_id))
and passage_ip_limiter.allow(state.client_ip)
):
await state.send_queue.put(
{
"type": "error",
"code": "rate_limited",
"message": "The way is still closing behind you. Give it a moment.",
}
)
return
traits = state.entity.get("traits", {})
layer = state.passage_layer
# Both twists draw from the room's real physical noise, with distinct
# contexts so a volatility collapse and a deceptive reveal can never be
# correlated with each other (or with any other draw in the app).
draw = passage.PassageDraw(
collapse=veil_float(state.entropy, f"passage:collapse:{layer}"),
deceit=veil_float(state.entropy, f"passage:deceit:{layer}"),
)
outcome = passage.resolve_layer(
traits, layer, draw, previous_lied=state.passage_lied
)
state.passage_lied = outcome.lied
if outcome.result == "collapsed":
# The rite restarts from `listen` — but the essence already earned
# stays credited. Taking it back would punish the seeker for the
# spirit's own instability.
state.passage_lied = False
state.passage_layer = outcome.next_layer or layer
if outcome.at_peace:
state.passage_crossed = True
item = None
if outcome.essence or outcome.at_peace:
async with session_maker() as db:
# Locked — same read-modify-write race as every other essence
# credit in this file (see _reward_summon).
user = await db.scalar(
select(User).where(User.id == state.user_id).with_for_update()
)
if user is not None:
if outcome.essence:
credit_essence(user, outcome.essence)
if outcome.at_peace:
# Completing the Passage is the most compassionate act
# in the game, exactly as a correct cross_over verdict
# is — so it moves favor by the same amount.
user.favor = judgment.clamp_favor(
user.favor + judgment.FAVOR_CORRECT_CROSS_OVER
)
if outcome.at_peace:
entity_row = await db.get(Entity, uuid.UUID(state.entity["id"]))
if entity_row is not None:
entity_row.at_peace = True
item = roll_item_drop("judgment")
if item is not None:
db.add(
InventoryItem(
user_id=state.user_id,
item_type=item["item_type"],
item_key=item["item_key"],
payload=item["payload"],
)
)
await db.commit()
await state.send_queue.put(_passage_frame(state, outcome))
if item is not None:
await state.send_queue.put({"type": "item_drop", "item": item})
# A 768px JPEG at quality 0.72 is well under 200KB, so ~350KB of base64 is a
# generous ceiling that still refuses anything pathological before it reaches
# the model.
@@ -1093,6 +1261,10 @@ async def session_socket(websocket: WebSocket) -> None:
await _handle_ritual_step(state, message)
elif msg_type == "judgment":
await _handle_judgment(state, message)
elif msg_type == "passage_start":
await _handle_passage_start(state)
elif msg_type == "passage_layer":
await _handle_passage_layer(state)
elif msg_type == "scry":
await _handle_scry(state, message)
except WebSocketDisconnect: