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>
This commit is contained in:
Indiana
2026-07-24 21:27:30 +00:00
parent f023b591b5
commit 36c4a9e6e4
7 changed files with 1541 additions and 19 deletions

View File

@@ -8,6 +8,10 @@ Protocol (client → server):
{"type": "anomaly", "source": SRC, ...} → {"type": "utterance", ...}
{"type": "question", "text": "..."} → reply_start / reply_token* / reply_end
{"type": "passive", "enabled": bool} → ambient wire loop on/off
{"type": "ritual_start"} → (begins a ritual attempt)
{"type": "ritual_step", "step": <int>} → (on the final step) ritual_complete
{"type": "judgment", "verdict": "trust" | "banish" | "test" | "cross_over"}
→ judgment_result
All server → client frames flow through a single sender task so concurrent
producers (ambient loop, reply streaming, TTS callbacks) never interleave on
@@ -26,11 +30,18 @@ from pathlib import Path
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from sqlalchemy import select
from app import judgment
from app.config import settings
from app.db import async_session_maker as _default_session_maker
from app.deps import SESSION_COOKIE_NAME
from app.entities import fallback_signature, signature_from_anomalies
from app.inventory import SUMMON_ESSENCE_TRICKLE, credit_essence, roll_item_drop, summon_drop_trigger
from app.inventory import (
RITUAL_SUCCESS_ESSENCE,
SUMMON_ESSENCE_TRICKLE,
credit_essence,
roll_item_drop,
summon_drop_trigger,
)
from app.llm.service import SpiritBusyError, spirit_service
from app.models.auth_session import AuthSession, hash_token
from app.models.contact_session import ContactSession
@@ -89,6 +100,16 @@ class SeanceState:
ambient_task: asyncio.Task | None = None
wire_jitter_history: list[float] = field(default_factory=list)
last_wire_anomaly_at: float = 0.0
# Workstream B (character-depth-ghost-log spec): ritual progress for the
# *current* entity — reset whenever a fresh presence is summoned (see
# _handle_summon) or a new ritual_start arrives.
ritual_steps: int = 0
ritual_completed: bool = False
ritual_success: 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.
tell_rng: random.Random = field(default_factory=random.Random)
# Active-session registry (spec: ESP32 sensor node, Workstream K): maps a
@@ -133,9 +154,24 @@ def serialize_entity(entity: Entity) -> dict:
"quotes": entity.sample_quotes,
"contact_count": entity.contact_count,
"discovered_at": entity.discovered_at.isoformat(),
# Workstream B: hidden ground truth, kept on the server-side
# SeanceState.entity dict for the ritual/judgment/tell handlers to
# read (state.entity["traits"]) — see `_public_entity` below for why
# this never reaches the wire directly.
"traits": entity.traits,
}
def _public_entity(entity: dict) -> dict:
"""The entity payload actually sent to the client in the `entity`
frame — everything `serialize_entity` produces *except* `traits`.
Hidden traits must never leak outside `ritual_complete` on success (the
contract's decoupling requirement, echoed in
frontend/src/lib/evilMeter.ts's comments); `frontend/src/lib/types.ts`'s
`SpiritEntity` type correspondingly has no `traits` field."""
return {key: value for key, value in entity.items() if key != "traits"}
def _client_ip(websocket: WebSocket) -> str:
host = websocket.client.host if websocket.client else None
return resolve_client_ip(websocket.headers, host)
@@ -237,28 +273,50 @@ async def _unique_entity_name(db, base_name: str) -> str:
async def _summon(state: SeanceState, channel: str) -> tuple[Entity, bool]:
"""Match this session's signature against the Codex, or mint a new entity."""
"""Match this session's signature against the Codex, or mint a new entity.
An at-peace entity (Workstream B: a spirit correctly helped to cross
over) is excluded from the match — it stays in the Codex forever but
can't be re-contacted. If its signature is what this session's anomaly
pattern hashes to, a *new* entity is minted instead. `Entity.signature`
is unique, so the new entity can't reuse the exact same string while the
retired row still holds it — it gets a salted variant of the same base
signature instead.
"""
signature = signature_from_anomalies(state.anomalies) or fallback_signature(
str(state.session_id)
)
async with session_maker() as db:
entity = await db.scalar(select(Entity).where(Entity.signature == signature))
entity = await db.scalar(
select(Entity).where(Entity.signature == signature, Entity.at_peace.is_(False))
)
is_new = entity is None
if is_new:
mint_signature = signature
retired = await db.scalar(select(Entity).where(Entity.signature == signature))
if retired is not None:
mint_signature = f"{signature}:{uuid.uuid4().hex[:8]}"
profile = await spirit_service.mint_profile(
signature, channel, state.anomalies, state.language
mint_signature, channel, state.anomalies, state.language
)
discoverer = await db.get(User, state.user_id)
favor = discoverer.favor if discoverer is not None else 0.0
entity = Entity(
name=await _unique_entity_name(db, profile["name"]),
epithet=profile["epithet"],
persona=profile["persona"],
rarity_tier=profile["rarity"],
signature=signature,
signature=mint_signature,
voice_profile=profile["voice"],
visual_profile=profile["visual"],
sample_quotes=profile["quotes"],
# Workstream B: signature-seeded traits, nudged by the
# discovering user's favor (app.judgment.apply_favor_bias) —
# never derived from/fed into the persona above.
traits=judgment.apply_favor_bias(profile["traits"], favor),
discovered_by=state.user_id,
contact_count=1,
)
@@ -286,11 +344,11 @@ async def _reward_summon(state: SeanceState) -> None:
summon (any mode), and — only when the summoned entity is high-rarity —
a roll for an item drop. The other two contract trigger points ("after a
correct judgment, a successful ritual") belong to Workstream B's
ritual/judgment WS handlers, which don't exist in this codebase yet;
`app.inventory` exposes the same `roll_item_drop`/`credit_essence`
helpers (plus the milestone essence constants) for those handlers to
call once they land, so the drop table and essence economy stay in one
place instead of being duplicated."""
ritual/judgment WS handlers (`_reward_ritual_success` / `_handle_judgment`
below), which call the same `app.inventory` `roll_item_drop`/
`credit_essence` helpers and milestone essence constants so the drop
table and essence economy stay in one place instead of being
duplicated."""
assert state.entity is not None
rarity = state.entity.get("rarity", "common")
@@ -338,14 +396,39 @@ async def _handle_summon(state: SeanceState) -> None:
await state.send_queue.put({"type": "status", "state": "summoning"})
entity, is_new = await _summon(state, state.mode if state.mode != "unknown" else "ouija")
state.entity = serialize_entity(entity)
# A fresh presence invalidates any in-progress/completed ritual from
# whatever was previously in this slot (mirrors the frontend reducer's
# 'entity' case in state/seance.tsx, which resets its own ritual/
# judgment UI state the same way).
state.ritual_steps = 0
state.ritual_completed = False
state.ritual_success = False
await state.send_queue.put(
{"type": "entity", "entity": state.entity, "is_new": is_new}
{"type": "entity", "entity": _public_entity(state.entity), "is_new": is_new}
)
await _reward_summon(state)
greeting = random.choice(state.entity["quotes"]) if state.entity["quotes"] else "I am here."
await _speak(state, "greeting", greeting)
# Workstream B: `tell` frames piggyback on the existing anomaly/reply
# handling rather than running their own timer — a fragment (ambient,
# frequent) rolls a lower chance than a direct reply (deliberate, a seeker
# just asked something), so tells feel like they're punctuating engagement
# rather than firing on a fixed clock.
TELL_CHANCE_ON_FRAGMENT = 0.2
TELL_CHANCE_ON_REPLY = 0.35
async def _maybe_tell(state: SeanceState, chance: float) -> None:
if state.entity is None:
return
if state.tell_rng.random() >= chance:
return
text = judgment.generate_tell(state.entity.get("traits", {}), state.tell_rng)
await state.send_queue.put({"type": "tell", "text": text})
async def _handle_anomaly(state: SeanceState, message: dict) -> None:
anomaly = {
"source": str(message.get("source", "unknown"))[:16],
@@ -378,6 +461,7 @@ async def _handle_anomaly(state: SeanceState, message: dict) -> None:
except SpiritBusyError:
return
await _speak(state, "fragment", fragment)
await _maybe_tell(state, TELL_CHANCE_ON_FRAGMENT)
async def _handle_question(state: SeanceState, text: str) -> None:
@@ -434,6 +518,7 @@ async def _handle_question(state: SeanceState, text: str) -> None:
await state.send_queue.put({"type": "reply_end", "id": str(reply_id), "text": reply})
if reply:
await _speak(state, "reply", reply, instability=1 - stability)
await _maybe_tell(state, TELL_CHANCE_ON_REPLY)
async def _ambient_loop(state: SeanceState) -> None:
@@ -485,6 +570,133 @@ async def _handle_passive(state: SeanceState, enabled: bool) -> None:
await state.send_queue.put({"type": "passive", "enabled": False})
# --- Workstream B: ritual + judgment (character-depth-ghost-log spec) ------
# How many `ritual_step` frames complete one attempt — matches
# frontend/src/lib/ritual.ts's RITUAL_TOTAL_STEPS (the 4-rune "align /
# breathe / trace / lock" sequence). The frontend owns the exact step count
# per the spec ("implementer's call"); this just has to agree with it.
RITUAL_STEPS_REQUIRED = 4
async def _handle_ritual_start(state: SeanceState) -> None:
if state.entity is None:
return # no presence to focus on — frontend already gates the button
state.ritual_steps = 0
state.ritual_completed = False
state.ritual_success = False
async def _reward_ritual_success(state: SeanceState) -> None:
"""Mirrors `_reward_summon`'s essence-credit + item-drop pattern for the
ritual milestone trigger point."""
item = None
async with session_maker() as db:
user = await db.get(User, state.user_id)
if user is None:
return
credit_essence(user, RITUAL_SUCCESS_ESSENCE)
item = roll_item_drop("ritual")
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()
if item is not None:
await state.send_queue.put({"type": "item_drop", "item": item})
async def _handle_ritual_step(state: SeanceState, message: dict) -> None:
if state.entity is None or state.ritual_completed:
return
if not isinstance(message.get("step"), int):
return
state.ritual_steps += 1
if state.ritual_steps < RITUAL_STEPS_REQUIRED:
return
traits = state.entity.get("traits", {})
success = judgment.roll_ritual_success(traits)
state.ritual_completed = True
state.ritual_success = success
revealed = dict(traits) if success else None
await state.send_queue.put(
{"type": "ritual_complete", "success": success, "revealed": revealed}
)
if success:
await _reward_ritual_success(state)
async def _handle_judgment(state: SeanceState, message: dict) -> None:
if state.entity is None:
return
verdict = message.get("verdict")
if verdict not in judgment.VERDICTS:
return
traits = state.entity.get("traits", {})
outcome = judgment.judge_verdict(
verdict,
traits,
ritual_completed=state.ritual_completed,
ritual_success=state.ritual_success,
)
item = None
# Skip the DB round-trip entirely when there's nothing to persist (e.g.
# `test` without a completed ritual, or a resisted cross_over) — the
# contract's "no crash, just no effect" for those cases.
if outcome.favor_delta or outcome.essence_delta or outcome.consequence in (
"reward",
"crossed_over",
):
async with session_maker() as db:
user = await db.get(User, state.user_id)
if user is not None:
if outcome.favor_delta:
user.favor = judgment.clamp_favor(user.favor + outcome.favor_delta)
if outcome.essence_delta:
credit_essence(user, outcome.essence_delta)
if outcome.consequence == "crossed_over":
entity_row = await db.get(Entity, uuid.UUID(state.entity["id"]))
if entity_row is not None:
entity_row.at_peace = True
if outcome.consequence in ("reward", "crossed_over"):
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(
{
"type": "judgment_result",
"correct": outcome.correct,
"favor_delta": outcome.favor_delta,
"essence_delta": outcome.essence_delta,
"at_peace": outcome.at_peace,
"consequence": outcome.consequence,
}
)
if item is not None:
await state.send_queue.put({"type": "item_drop", "item": item})
@router.websocket("/ws/session")
async def session_socket(websocket: WebSocket) -> None:
user_id = await _authenticate(websocket)
@@ -539,6 +751,12 @@ async def session_socket(websocket: WebSocket) -> None:
await _handle_question(state, message["text"])
elif msg_type == "passive":
await _handle_passive(state, bool(message.get("enabled")))
elif msg_type == "ritual_start":
await _handle_ritual_start(state)
elif msg_type == "ritual_step":
await _handle_ritual_step(state, message)
elif msg_type == "judgment":
await _handle_judgment(state, message)
except WebSocketDisconnect:
pass
finally: