Files
qtalker---/backend/tests/test_judgment.py
Indiana 36c4a9e6e4 feat: ritual + judgment + favor + cross-over (Workstream B)
Implements Workstream B of the character-depth-ghost-log spec:
ritual_start/ritual_step/judgment WS handlers, the pure judgment.py
logic module, and the User.favor / Entity.at_peace columns + migration.

- app/judgment.py: pure ritual success roll (base 65%, floored at 30%,
  driven by an entity's power+deceptiveness difficulty), the "stuck
  spirit" cross_over rule (alignment >= 0.5 and volatility > 0.6, ~20%
  of entities), judgment correctness/favor-delta/essence-delta/
  consequence resolution for all four verdicts, favor clamping, the
  favor-to-trait-roll bias applied at mint time, and tell-line
  generation (opaque behavioral flavor text, never a raw stat).
- app/ws.py: wires ritual_start/ritual_step/judgment frames, emits
  ritual_complete/tell/judgment_result/item_drop per the spec's
  Contract; traits are added to serialize_entity for internal
  server-side use but stripped from the outbound `entity` frame via a
  new _public_entity helper so hidden ground truth never reaches the
  client outside ritual_complete; _summon excludes at-peace entities
  from signature re-contact and mints a fresh (salted-signature) entity
  instead; new entities' traits are nudged by the discovering user's
  favor before being persisted.
- models/user.py, models/entity.py, main.py: User.favor and
  Entity.at_peace columns plus their idempotent ADD COLUMN IF NOT
  EXISTS migration lines in lifespan, alongside the existing ones.
- tests/test_judgment.py, tests/test_ws_ritual_judgment.py: 56 new
  tests covering the ritual/judgment correctness matrix, favor
  clamping/bias, essence crediting, at_peace persistence + re-contact,
  and the entity-frame trait leak guard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 21:27:30 +00:00

364 lines
12 KiB
Python

import random
import pytest
from app.judgment import (
FAVOR_CORRECT_BANISH,
FAVOR_CORRECT_CROSS_OVER,
FAVOR_CORRECT_TRUST,
FAVOR_CROSS_OVER_FAIL,
FAVOR_TEST,
FAVOR_WRONG_BANISH,
FAVOR_WRONG_TRUST,
RITUAL_BASE_SUCCESS_CHANCE,
RITUAL_MIN_SUCCESS_CHANCE,
STUCK_VOLATILITY_THRESHOLD,
apply_favor_bias,
clamp_favor,
generate_tell,
is_stuck_spirit,
judge_verdict,
roll_ritual_success,
)
from app.inventory import CORRECT_JUDGMENT_ESSENCE, CROSS_OVER_ESSENCE
def _traits(alignment=0.5, power=0.5, volatility=0.5, deceptiveness=0.5):
return {
"alignment": alignment,
"power": power,
"volatility": volatility,
"deceptiveness": deceptiveness,
}
# --- clamp_favor -------------------------------------------------------
def test_clamp_favor_within_range_unchanged():
assert clamp_favor(0.3) == 0.3
def test_clamp_favor_clamps_above_max():
assert clamp_favor(5.0) == 1.0
def test_clamp_favor_clamps_below_min():
assert clamp_favor(-5.0) == -1.0
def test_clamp_favor_at_exact_bounds():
assert clamp_favor(1.0) == 1.0
assert clamp_favor(-1.0) == -1.0
# --- roll_ritual_success -------------------------------------------------
def test_ritual_success_always_true_when_rng_below_chance():
rng = random.Random()
# An easy entity (low power/deceptiveness) sits at the base chance;
# forcing rng.random() to 0 always beats any positive chance.
monkey_rng = random.Random(0)
monkey_rng.random = lambda: 0.0 # type: ignore[method-assign]
assert roll_ritual_success(_traits(power=0.0, deceptiveness=0.0), monkey_rng) is True
def test_ritual_success_always_false_when_rng_at_one():
monkey_rng = random.Random(0)
monkey_rng.random = lambda: 0.999999 # type: ignore[method-assign]
assert roll_ritual_success(_traits(power=0.0, deceptiveness=0.0), monkey_rng) is False
def test_ritual_harder_entity_has_lower_success_chance():
# Same rng draw, easy vs. hard entity: the hard one should fail where
# the easy one succeeds, for a draw threaded between the two chances.
easy = _traits(power=0.0, deceptiveness=0.0)
hard = _traits(power=1.0, deceptiveness=1.0)
fixed_draw = (RITUAL_BASE_SUCCESS_CHANCE + RITUAL_MIN_SUCCESS_CHANCE) / 2
rng_easy = random.Random(0)
rng_easy.random = lambda: fixed_draw # type: ignore[method-assign]
rng_hard = random.Random(0)
rng_hard.random = lambda: fixed_draw # type: ignore[method-assign]
assert roll_ritual_success(easy, rng_easy) is True
assert roll_ritual_success(hard, rng_hard) is False
def test_ritual_success_chance_never_below_floor():
# Even the worst-case entity must be beatable — a low enough draw always
# succeeds.
rng = random.Random(0)
rng.random = lambda: RITUAL_MIN_SUCCESS_CHANCE - 0.01 # type: ignore[method-assign]
assert roll_ritual_success(_traits(power=1.0, deceptiveness=1.0), rng) is True
def test_ritual_success_alignment_and_volatility_dont_affect_odds():
fixed_draw = 0.5
rng_a = random.Random(0)
rng_a.random = lambda: fixed_draw # type: ignore[method-assign]
rng_b = random.Random(0)
rng_b.random = lambda: fixed_draw # type: ignore[method-assign]
result_a = roll_ritual_success(_traits(alignment=0.0, volatility=0.0, power=0.4, deceptiveness=0.4), rng_a)
result_b = roll_ritual_success(_traits(alignment=1.0, volatility=1.0, power=0.4, deceptiveness=0.4), rng_b)
assert result_a == result_b
def test_ritual_success_uses_fresh_rng_by_default_and_varies():
# No injected rng: repeated calls against the same traits should not
# all agree (proves it isn't signature/deterministically seeded).
outcomes = {roll_ritual_success(_traits()) for _ in range(200)}
assert outcomes == {True, False}
# --- is_stuck_spirit -----------------------------------------------------
def test_stuck_spirit_requires_benevolent_and_high_volatility():
assert is_stuck_spirit(_traits(alignment=0.7, volatility=0.9)) is True
def test_stuck_spirit_false_for_demon_even_if_volatile():
assert is_stuck_spirit(_traits(alignment=0.2, volatility=0.95)) is False
def test_stuck_spirit_false_for_calm_benevolent_spirit():
assert is_stuck_spirit(_traits(alignment=0.8, volatility=0.3)) is False
def test_stuck_spirit_boundary_volatility_not_stuck():
assert is_stuck_spirit(_traits(alignment=0.9, volatility=STUCK_VOLATILITY_THRESHOLD)) is False
def test_stuck_spirit_boundary_alignment_is_stuck():
assert is_stuck_spirit(_traits(alignment=0.5, volatility=0.99)) is True
# --- judge_verdict: trust -------------------------------------------------
def test_trust_correct_on_benevolent_spirit():
outcome = judge_verdict("trust", _traits(alignment=0.8))
assert outcome.correct is True
assert outcome.consequence == "reward"
assert outcome.favor_delta == FAVOR_CORRECT_TRUST
assert outcome.essence_delta == CORRECT_JUDGMENT_ESSENCE
assert outcome.at_peace is False
def test_trust_wrong_on_demon():
outcome = judge_verdict("trust", _traits(alignment=0.1))
assert outcome.correct is False
assert outcome.consequence == "escalation"
assert outcome.favor_delta == FAVOR_WRONG_TRUST
assert outcome.essence_delta == 0
def test_trust_boundary_alignment_counts_as_benevolent():
outcome = judge_verdict("trust", _traits(alignment=0.5))
assert outcome.correct is True
assert outcome.consequence == "reward"
# --- judge_verdict: banish -------------------------------------------------
def test_banish_correct_on_demon():
outcome = judge_verdict("banish", _traits(alignment=0.1))
assert outcome.correct is True
assert outcome.consequence == "reward"
assert outcome.favor_delta == FAVOR_CORRECT_BANISH
assert outcome.essence_delta == CORRECT_JUDGMENT_ESSENCE
def test_banish_wrong_on_real_spirit():
outcome = judge_verdict("banish", _traits(alignment=0.9))
assert outcome.correct is False
assert outcome.consequence == "withdrawal"
assert outcome.favor_delta == FAVOR_WRONG_BANISH
assert outcome.essence_delta == 0
def test_wrong_trust_penalty_larger_magnitude_than_wrong_banish():
trust_outcome = judge_verdict("trust", _traits(alignment=0.0))
banish_outcome = judge_verdict("banish", _traits(alignment=1.0))
assert abs(trust_outcome.favor_delta) > abs(banish_outcome.favor_delta)
assert trust_outcome.favor_delta < 0
assert banish_outcome.favor_delta < 0
def test_all_favor_deltas_are_small_and_in_range():
for delta in (
FAVOR_CORRECT_TRUST,
FAVOR_CORRECT_BANISH,
FAVOR_WRONG_TRUST,
FAVOR_WRONG_BANISH,
FAVOR_CORRECT_CROSS_OVER,
FAVOR_CROSS_OVER_FAIL,
FAVOR_TEST,
):
assert -1.0 <= delta <= 1.0
assert abs(delta) <= 0.2 # single-digit percent of the [-1, 1] range
# --- judge_verdict: cross_over ---------------------------------------------
def test_cross_over_correct_on_stuck_spirit():
outcome = judge_verdict("cross_over", _traits(alignment=0.8, volatility=0.9))
assert outcome.correct is True
assert outcome.consequence == "crossed_over"
assert outcome.at_peace is True
assert outcome.favor_delta == FAVOR_CORRECT_CROSS_OVER
assert outcome.essence_delta == CROSS_OVER_ESSENCE
def test_cross_over_resisted_on_demon():
outcome = judge_verdict("cross_over", _traits(alignment=0.1, volatility=0.9))
assert outcome.correct is False
assert outcome.consequence == "resisted"
assert outcome.at_peace is False
assert outcome.favor_delta == 0
assert outcome.essence_delta == 0
def test_cross_over_resisted_on_non_stuck_real_spirit():
outcome = judge_verdict("cross_over", _traits(alignment=0.8, volatility=0.2))
assert outcome.correct is False
assert outcome.consequence == "resisted"
assert outcome.at_peace is False
assert outcome.favor_delta == 0
assert outcome.essence_delta == 0
def test_cross_over_essence_is_largest_reward_of_any_outcome():
trust = judge_verdict("trust", _traits(alignment=0.9))
banish = judge_verdict("banish", _traits(alignment=0.1))
crossed = judge_verdict("cross_over", _traits(alignment=0.8, volatility=0.9))
assert crossed.essence_delta > trust.essence_delta
assert crossed.essence_delta > banish.essence_delta
# --- judge_verdict: test ---------------------------------------------------
def test_test_verdict_without_completed_ritual_has_no_effect():
outcome = judge_verdict("test", _traits(alignment=0.9), ritual_completed=False)
assert outcome.correct is False
assert outcome.consequence == "neutral"
assert outcome.favor_delta == 0
assert outcome.essence_delta == 0
assert outcome.at_peace is False
def test_test_verdict_with_successful_completed_ritual():
outcome = judge_verdict(
"test", _traits(alignment=0.9), ritual_completed=True, ritual_success=True
)
assert outcome.correct is True
assert outcome.consequence == "neutral"
assert outcome.favor_delta == 0
assert outcome.essence_delta == 0
def test_test_verdict_with_failed_completed_ritual():
outcome = judge_verdict(
"test", _traits(alignment=0.9), ritual_completed=True, ritual_success=False
)
assert outcome.correct is False
assert outcome.consequence == "neutral"
assert outcome.favor_delta == 0
assert outcome.essence_delta == 0
def test_judge_verdict_rejects_unknown_verdict():
with pytest.raises(ValueError):
judge_verdict("smite", _traits())
# --- apply_favor_bias -------------------------------------------------------
def test_favor_bias_zero_favor_is_a_no_op():
traits = _traits(volatility=0.5, deceptiveness=0.5)
biased = apply_favor_bias(traits, 0.0)
assert biased["volatility"] == pytest.approx(traits["volatility"])
assert biased["deceptiveness"] == pytest.approx(traits["deceptiveness"])
def test_favor_bias_positive_favor_lowers_volatility_and_deceptiveness():
traits = _traits(volatility=0.5, deceptiveness=0.5)
biased = apply_favor_bias(traits, 1.0)
assert biased["volatility"] < traits["volatility"]
assert biased["deceptiveness"] < traits["deceptiveness"]
def test_favor_bias_negative_favor_raises_volatility_and_deceptiveness():
traits = _traits(volatility=0.5, deceptiveness=0.5)
biased = apply_favor_bias(traits, -1.0)
assert biased["volatility"] > traits["volatility"]
assert biased["deceptiveness"] > traits["deceptiveness"]
def test_favor_bias_leaves_alignment_and_power_untouched():
traits = _traits(alignment=0.3, power=0.7, volatility=0.5, deceptiveness=0.5)
biased = apply_favor_bias(traits, 1.0)
assert biased["alignment"] == traits["alignment"]
assert biased["power"] == traits["power"]
def test_favor_bias_clamps_to_valid_range():
traits = _traits(volatility=0.02, deceptiveness=0.98)
biased = apply_favor_bias(traits, -1.0)
assert 0.0 <= biased["volatility"] <= 1.0
assert 0.0 <= biased["deceptiveness"] <= 1.0
biased_up = apply_favor_bias(traits, 1.0)
assert 0.0 <= biased_up["deceptiveness"] <= 1.0
def test_favor_bias_effect_size_is_small():
traits = _traits(volatility=0.5, deceptiveness=0.5)
biased = apply_favor_bias(traits, 1.0)
assert abs(biased["volatility"] - traits["volatility"]) <= 0.15
assert abs(biased["deceptiveness"] - traits["deceptiveness"]) <= 0.15
def test_favor_bias_out_of_range_favor_gets_clamped_first():
traits = _traits(volatility=0.5, deceptiveness=0.5)
extreme = apply_favor_bias(traits, 5.0)
clamped = apply_favor_bias(traits, 1.0)
assert extreme == clamped
# --- generate_tell -----------------------------------------------------------
def test_generate_tell_never_leaks_a_raw_number():
rng = random.Random(42)
traits = _traits(alignment=0.9, power=0.1, volatility=0.95, deceptiveness=0.05)
for _ in range(50):
text = generate_tell(traits, rng)
assert isinstance(text, str) and text
# No digits anywhere in the line — the whole point is never
# surfacing a stat number.
assert not any(ch.isdigit() for ch in text)
def test_generate_tell_is_deterministic_given_same_rng_state():
traits = _traits()
rng_a = random.Random(7)
rng_b = random.Random(7)
lines_a = [generate_tell(traits, rng_a) for _ in range(10)]
lines_b = [generate_tell(traits, rng_b) for _ in range(10)]
assert lines_a == lines_b
def test_generate_tell_varies_across_draws():
traits = _traits()
rng = random.Random(99)
lines = {generate_tell(traits, rng) for _ in range(30)}
assert len(lines) > 1