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:
292
backend/tests/test_passage.py
Normal file
292
backend/tests/test_passage.py
Normal file
@@ -0,0 +1,292 @@
|
||||
"""Workstream L: the pure Passage core (app/passage.py).
|
||||
|
||||
Every draw here is PINNED — no `random`, no `veil_float`, no clock — so a
|
||||
twist that fires in this file fires on every run, forever. The only source
|
||||
of uncertainty in the real handler is `veil_float`, and it enters through
|
||||
`PassageDraw`, which these tests supply directly.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.inventory import CROSS_OVER_ESSENCE
|
||||
from app.judgment import is_stuck_spirit
|
||||
from app.passage import (
|
||||
COLLAPSIBLE_LAYERS,
|
||||
LAYER_ESSENCE,
|
||||
LAYERS,
|
||||
PassageDraw,
|
||||
collapse_chance,
|
||||
deceit_chance,
|
||||
next_layer,
|
||||
resolve_layer,
|
||||
)
|
||||
|
||||
# A spirit that genuinely qualifies for crossing (judgment.is_stuck_spirit:
|
||||
# alignment >= 0.5 AND volatility > 0.6) and is too incurious to lie
|
||||
# (deceptiveness 0.1 is well under DECEIT_FLOOR 0.55, so deceit_chance is
|
||||
# exactly 0 and no draw can make it lie).
|
||||
#
|
||||
# It CAN still collapse. COLLAPSE_FLOOR is 0.5, deliberately below
|
||||
# judgment's stuck bar of 0.6 (see passage.py), so volatility 0.61 gives a
|
||||
# real ~9.9% collapse chance on the collapsible layers. Tests that want an
|
||||
# uninterrupted rite must therefore hold the collapse draw — see
|
||||
# test_full_rite_on_a_calm_stuck_spirit_opens_every_layer_and_crosses.
|
||||
CALM_STUCK = {"alignment": 0.8, "power": 0.4, "volatility": 0.61, "deceptiveness": 0.1}
|
||||
# A demon: alignment below judgment's benevolence threshold.
|
||||
DEMON = {"alignment": 0.1, "power": 0.9, "volatility": 0.9, "deceptiveness": 0.9}
|
||||
# Maximally cagey (always able to lie) but perfectly steady (never collapses).
|
||||
LIAR = {"alignment": 0.9, "power": 0.5, "volatility": 0.2, "deceptiveness": 1.0}
|
||||
# Maximally unstable but perfectly straight.
|
||||
UNSTABLE = {"alignment": 0.9, "power": 0.5, "volatility": 1.0, "deceptiveness": 0.0}
|
||||
|
||||
# Draw sentinels. 0.0 is below every possible chance (the twist fires);
|
||||
# 0.999 is above every possible chance (it does not).
|
||||
FIRES = 0.0
|
||||
HOLDS = 0.999
|
||||
NO_TWIST = PassageDraw(collapse=HOLDS, deceit=HOLDS)
|
||||
|
||||
|
||||
# --- shape ----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_layers_are_the_five_named_beats_in_order():
|
||||
assert LAYERS == ("listen", "name", "unbind", "open", "release")
|
||||
|
||||
|
||||
def test_layer_rewards_escalate_and_the_last_pays_the_existing_cross_over_essence():
|
||||
rewards = [LAYER_ESSENCE[layer] for layer in LAYERS]
|
||||
assert rewards == sorted(rewards)
|
||||
assert len(set(rewards)) == len(rewards) # strictly escalating
|
||||
assert LAYER_ESSENCE["release"] == CROSS_OVER_ESSENCE
|
||||
|
||||
|
||||
def test_next_layer_walks_the_track_and_ends():
|
||||
assert [next_layer(layer) for layer in LAYERS] == [
|
||||
"name",
|
||||
"unbind",
|
||||
"open",
|
||||
"release",
|
||||
None,
|
||||
]
|
||||
|
||||
|
||||
def test_unknown_layer_raises():
|
||||
with pytest.raises(ValueError):
|
||||
resolve_layer(CALM_STUCK, "banish", NO_TWIST)
|
||||
|
||||
|
||||
# --- the happy path: five beats, five reveals, one crossing -----------------
|
||||
|
||||
|
||||
def test_full_rite_on_a_calm_stuck_spirit_opens_every_layer_and_crosses():
|
||||
layer = LAYERS[0]
|
||||
seen = []
|
||||
total = 0
|
||||
# collapse HOLDS: volatility 0.61 is above COLLAPSE_FLOOR (0.5), so a
|
||||
# firing collapse draw would send every layer back to `listen` and this
|
||||
# loop would never terminate. deceit still FIRES, which is the point —
|
||||
# it proves a spirit under DECEIT_FLOOR cannot lie even when the draw
|
||||
# says it should.
|
||||
# The bound is a safety net, not a limit: five layers plus any collapse
|
||||
# should finish immediately, and an unbounded `while` turned a wrong
|
||||
# assertion into a hung test run rather than a failure.
|
||||
for _ in range(len(LAYERS) * 4):
|
||||
if layer is None:
|
||||
break
|
||||
outcome = resolve_layer(CALM_STUCK, layer, PassageDraw(collapse=HOLDS, deceit=FIRES))
|
||||
seen.append((outcome.layer, outcome.result))
|
||||
total += outcome.essence
|
||||
assert outcome.lied is False # deceptiveness 0.1 is under the floor
|
||||
layer = outcome.next_layer
|
||||
assert layer is None, "the rite never terminated"
|
||||
|
||||
assert seen == [
|
||||
("listen", "opened"),
|
||||
("name", "opened"),
|
||||
("unbind", "opened"),
|
||||
("open", "opened"),
|
||||
("release", "crossed"),
|
||||
]
|
||||
assert total == sum(LAYER_ESSENCE.values())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"layer,trait",
|
||||
[
|
||||
("listen", "volatility"),
|
||||
("name", "deceptiveness"),
|
||||
("unbind", "power"),
|
||||
("open", "alignment"),
|
||||
],
|
||||
)
|
||||
def test_each_opened_layer_reveals_that_layer_s_real_trait(layer, trait):
|
||||
outcome = resolve_layer(CALM_STUCK, layer, NO_TWIST)
|
||||
assert outcome.result == "opened"
|
||||
assert outcome.reveal is not None
|
||||
assert outcome.reveal.trait == trait
|
||||
# The REAL value off the entity's traits dict — no second hidden state.
|
||||
assert outcome.reveal.value == pytest.approx(CALM_STUCK[trait])
|
||||
|
||||
|
||||
def test_release_has_no_trait_reveal_its_truth_is_the_crossing_itself():
|
||||
outcome = resolve_layer(CALM_STUCK, "release", NO_TWIST)
|
||||
assert outcome.result == "crossed"
|
||||
assert outcome.reveal is None
|
||||
assert outcome.at_peace is True
|
||||
assert outcome.essence == CROSS_OVER_ESSENCE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value,expected",
|
||||
[(0.9, "high"), (0.6, "high"), (0.5, "mid"), (0.4, "low"), (0.05, "low")],
|
||||
)
|
||||
def test_reveal_bands_match_the_trait_value(value, expected):
|
||||
traits = {"alignment": 0.9, "power": value, "volatility": 0.7, "deceptiveness": 0.0}
|
||||
outcome = resolve_layer(traits, "unbind", NO_TWIST)
|
||||
assert outcome.reveal.band == expected
|
||||
|
||||
|
||||
# --- twist: low alignment (a demon) makes release resist --------------------
|
||||
|
||||
|
||||
def test_a_demon_is_resisted_at_release_no_matter_what_the_draws_say():
|
||||
# Every corner of the draw space — the invariant is checked before any
|
||||
# draw is consulted, so none of these can cross a demon.
|
||||
for collapse in (0.0, 0.5, 0.999):
|
||||
for deceit in (0.0, 0.5, 0.999):
|
||||
outcome = resolve_layer(
|
||||
DEMON, "release", PassageDraw(collapse=collapse, deceit=deceit)
|
||||
)
|
||||
assert outcome.result == "resisted"
|
||||
assert outcome.at_peace is False
|
||||
assert outcome.essence == 0
|
||||
assert outcome.next_layer is None
|
||||
|
||||
|
||||
def test_a_benevolent_but_not_stuck_spirit_also_resists_matching_judgment():
|
||||
settled = {"alignment": 0.9, "power": 0.5, "volatility": 0.2, "deceptiveness": 0.0}
|
||||
assert is_stuck_spirit(settled) is False # same rule judgment.py uses
|
||||
outcome = resolve_layer(settled, "release", PassageDraw(collapse=FIRES, deceit=FIRES))
|
||||
assert outcome.result == "resisted"
|
||||
assert outcome.at_peace is False
|
||||
|
||||
|
||||
def test_a_demon_can_still_walk_the_earlier_layers_and_learn_of_itself():
|
||||
# The rite is not gated — a demon opens `open` and the reveal is its own
|
||||
# real (low) alignment, which is precisely the warning the seeker gets.
|
||||
outcome = resolve_layer(DEMON, "open", PassageDraw(collapse=HOLDS, deceit=HOLDS))
|
||||
assert outcome.result == "opened"
|
||||
assert outcome.reveal.trait == "alignment"
|
||||
assert outcome.reveal.value == pytest.approx(0.1)
|
||||
assert outcome.reveal.band == "low"
|
||||
|
||||
|
||||
# --- twist: high deceptiveness lies -----------------------------------------
|
||||
|
||||
|
||||
def test_high_deceptiveness_can_reveal_a_mirrored_false_trait():
|
||||
outcome = resolve_layer(LIAR, "unbind", PassageDraw(collapse=HOLDS, deceit=FIRES))
|
||||
assert outcome.result == "opened"
|
||||
assert outcome.lied is True
|
||||
# power is really 0.5; the lie mirrors it.
|
||||
assert outcome.reveal.value == pytest.approx(1.0 - LIAR["power"])
|
||||
|
||||
|
||||
def test_the_lie_is_not_flagged_on_the_frame_that_carries_it():
|
||||
outcome = resolve_layer(LIAR, "listen", PassageDraw(collapse=HOLDS, deceit=FIRES))
|
||||
assert outcome.lied is True
|
||||
# `unreliable_layer` is what the client renders; it stays empty here, so
|
||||
# the seeker has no way to know at the time.
|
||||
assert outcome.unreliable_layer is None
|
||||
|
||||
|
||||
def test_the_previous_lie_surfaces_only_in_hindsight_on_the_next_layer():
|
||||
outcome = resolve_layer(LIAR, "name", NO_TWIST, previous_lied=True)
|
||||
assert outcome.unreliable_layer == "listen"
|
||||
|
||||
|
||||
def test_hindsight_never_points_before_the_first_layer():
|
||||
outcome = resolve_layer(LIAR, "listen", NO_TWIST, previous_lied=True)
|
||||
assert outcome.unreliable_layer is None
|
||||
|
||||
|
||||
def test_a_straight_spirit_can_never_lie_on_any_draw():
|
||||
assert deceit_chance(UNSTABLE) == 0.0
|
||||
for layer in ("listen", "name", "unbind", "open"):
|
||||
outcome = resolve_layer(UNSTABLE, layer, PassageDraw(collapse=HOLDS, deceit=FIRES))
|
||||
assert outcome.lied is False
|
||||
assert outcome.reveal.value == pytest.approx(UNSTABLE[outcome.reveal.trait])
|
||||
|
||||
|
||||
def test_deceit_chance_is_zero_at_the_floor_and_maxes_at_one():
|
||||
assert deceit_chance({"deceptiveness": 0.55}) == 0.0
|
||||
assert deceit_chance({"deceptiveness": 0.2}) == 0.0
|
||||
assert deceit_chance({"deceptiveness": 1.0}) == pytest.approx(0.40)
|
||||
|
||||
|
||||
def test_a_draw_exactly_at_the_chance_does_not_lie():
|
||||
# Strict `<`, pinned at the boundary: no off-by-one flakiness.
|
||||
chance = deceit_chance(LIAR)
|
||||
assert resolve_layer(LIAR, "listen", PassageDraw(collapse=HOLDS, deceit=chance)).lied is False
|
||||
|
||||
|
||||
# --- twist: high volatility collapses the rite ------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("layer", sorted(COLLAPSIBLE_LAYERS))
|
||||
def test_high_volatility_collapses_a_middle_layer_back_to_listen(layer):
|
||||
outcome = resolve_layer(UNSTABLE, layer, PassageDraw(collapse=FIRES, deceit=HOLDS))
|
||||
assert outcome.result == "collapsed"
|
||||
assert outcome.next_layer == "listen"
|
||||
assert outcome.reveal is None
|
||||
# No essence from the collapsed beat — but nothing is taken back either:
|
||||
# a collapse never produces a negative number for the caller to debit.
|
||||
assert outcome.essence == 0
|
||||
assert outcome.at_peace is False
|
||||
|
||||
|
||||
def test_listen_never_collapses_a_collapse_back_to_itself_would_be_a_no_op():
|
||||
assert collapse_chance(UNSTABLE, "listen") == 0.0
|
||||
outcome = resolve_layer(UNSTABLE, "listen", PassageDraw(collapse=FIRES, deceit=HOLDS))
|
||||
assert outcome.result == "opened"
|
||||
|
||||
|
||||
def test_release_never_collapses_so_the_crossable_are_not_the_uncrossable():
|
||||
# UNSTABLE qualifies as stuck (alignment 0.9, volatility 1.0) — the very
|
||||
# profile with the highest collapse chance. It must still be able to
|
||||
# finish the rite.
|
||||
assert is_stuck_spirit(UNSTABLE) is True
|
||||
assert collapse_chance(UNSTABLE, "release") == 0.0
|
||||
outcome = resolve_layer(UNSTABLE, "release", PassageDraw(collapse=FIRES, deceit=FIRES))
|
||||
assert outcome.result == "crossed"
|
||||
|
||||
|
||||
def test_a_steady_spirit_can_never_collapse_on_any_draw():
|
||||
assert collapse_chance(LIAR, "unbind") == 0.0
|
||||
outcome = resolve_layer(LIAR, "unbind", PassageDraw(collapse=FIRES, deceit=HOLDS))
|
||||
assert outcome.result == "opened"
|
||||
|
||||
|
||||
def test_collapse_chance_is_zero_at_the_floor_and_maxes_at_one():
|
||||
assert collapse_chance({"volatility": 0.5}, "unbind") == 0.0
|
||||
assert collapse_chance({"volatility": 1.0}, "unbind") == pytest.approx(0.45)
|
||||
|
||||
|
||||
def test_collapse_beats_the_lie_a_layer_that_falls_apart_reveals_nothing():
|
||||
both = {"alignment": 0.9, "power": 0.5, "volatility": 1.0, "deceptiveness": 1.0}
|
||||
outcome = resolve_layer(both, "unbind", PassageDraw(collapse=FIRES, deceit=FIRES))
|
||||
assert outcome.result == "collapsed"
|
||||
assert outcome.lied is False
|
||||
assert outcome.reveal is None
|
||||
|
||||
|
||||
# --- purity ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolve_layer_is_deterministic_and_never_mutates_its_traits():
|
||||
traits = dict(LIAR)
|
||||
draw = PassageDraw(collapse=0.31, deceit=0.07)
|
||||
first = resolve_layer(traits, "unbind", draw)
|
||||
for _ in range(50):
|
||||
assert resolve_layer(traits, "unbind", draw) == first
|
||||
assert traits == LIAR
|
||||
Reference in New Issue
Block a user