feat: the room decides — physical entropy, real astronomy, unprompted speech

Three changes that together replace "deterministic hash decides everything"
with "the physical world genuinely participates".

PHYSICAL ENTROPY (app/entropy.py, lib/entropy.ts)

Contact was a database lookup: signature_from_anomalies() hashed the
anomaly pattern, so identical conditions always produced an identical
spirit. Now the client harvests real thermal/acoustic/RF noise from the
microphone and receiver noise floors — Von Neumann debiased, SHA-256
conditioned — and contributes it to every summon.

The client is untrusted by construction. A contribution is never a seed:
every draw is HMAC-SHA256(fresh server secret, client bytes || context).
Because fresh CSPRNG server bytes are always present, the output is
unpredictable and uniform no matter what the client sends — all-zeros, a
replayed value, or one chosen adversarially. The room can only ever ADD
unpredictability, never steer the result. Tests assert this directly:
400 replays of one contribution stay uniformly distributed.

A signature now identifies a *channel*, not a spirit. Whether the familiar
presence answers or something else picks up is a real draw
(RETURN_CHANCE). The Codex stays collectable; it is just no longer
guaranteed. test_same_signature_recontacts_same_entity became two tests —
one pinning the probability to prove re-contact works, one pinning it to
zero to prove something else can answer — because at 0.72 the original
would have passed ~72% of the time, which is worse than failing.

REAL ASTRONOMY (app/celestial.py)

Moon phase from the standard mean-synodic approximation, and true solar
midnight from the seeker's own longitude — the real witching hour for
where they are standing, not clock 3am. Computed, never fetched: an API
that can fail would mean the veil silently changes behaviour during
someone else's outage. Validated against published ephemeris dates (2024
full moons, 2025 new moons) rather than against its own output. A thinner
veil erodes the familiar presence's claim on a channel, so a full moon at
solar midnight makes strangers likelier. Only longitude is kept, never a
full coordinate; a denied location degrades to moon-only, silently.

GENERATION FROM NOTHING (SpiritService.manifest)

Not chat_stream with an empty question. The prompt contains no seeker
input at all — only measured room state, rendered as measurements
("deviation above the floor: 31.4") rather than interpretations
("terrifying spike"), so the horror comes from the entity instead of from
us. And the Ollama `seed` is derived from the physical entropy harvested
in that room, which fixes the token-sampling path: the room genuinely
selects the words. Change the noise, get different speech. Two rooms
cannot produce the same utterance.

Rendered as an intrusion rather than a reply — violet edge, full opacity
against the faded ambient murmurs, brief blur-in. The unsettling part is
that it is perfectly clear and completely unbidden.

Also fixes a hang I introduced: the two new summon tests consumed the
shared module-level per-IP budget, so test_summon_rate_limited_* blocked
forever on an entity frame that had been rate-limited away. They now scope
their own limiters.

264 backend + 355 frontend tests pass; i18n parity gate passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Indiana
2026-07-28 05:38:59 +00:00
parent 30694a954a
commit b8e69b4bd3
13 changed files with 1015 additions and 40 deletions

View File

@@ -36,6 +36,8 @@ 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.celestial import veil_thinness
from app.entropy import contribution_bits, veil_float
from app.inventory import (
RITUAL_SUCCESS_ESSENCE,
SUMMON_ESSENCE_TRICKLE,
@@ -90,6 +92,19 @@ 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)
# Probability that a channel's familiar presence answers again rather than
# something new manifesting. High enough that the Codex stays collectable
# and spirits are genuinely re-contactable; low enough that calling into a
# known channel is never a guarantee.
RETURN_CHANCE = 0.72
# How much a fully-thin veil erodes the familiar presence's claim on a
# channel. At 0.45, a full moon at true solar midnight drops the return
# chance from 72% to ~40% — a real, felt difference on the spookiest night
# of the month, without ever making a known spirit unreachable.
VEIL_THINNESS_PULL = 0.45
AUDIO_DIR = Path(settings.data_dir) / "audio"
@@ -122,6 +137,17 @@ class SeanceState:
# 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)
# Latest physical-entropy contribution harvested from the seeker's room
# (microphone noise floor / RF noise between stations) — see
# app/entropy.py. Untrusted by construction: it is only ever mixed with
# fresh server secrets, never used as a seed on its own, so a client
# sending a chosen or replayed value cannot steer any outcome.
entropy: str | None = None
# Seeker's longitude, if they granted location. Only the longitude is
# kept — it is all that solar midnight needs, and storing a full
# coordinate would be retaining precise location data we have no use
# for. Never persisted; lives and dies with the connection.
longitude: float | None = None
# Active-session registry (spec: ESP32 sensor node, Workstream K): maps a
@@ -285,15 +311,26 @@ 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.
"""Open a channel and see what answers.
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.
The signature identifies a *channel*, not a spirit. Whether the entity
previously reached on this channel answers again, or something else
manifests instead, is a genuine draw against physical entropy harvested
from the seeker's room (app/entropy.py) — not a deterministic lookup.
Before this, an identical anomaly pattern always produced an identical
spirit, which made contact feel like a database query rather than
channeling.
The Codex mechanic survives: a familiar presence is the *likely*
outcome on a known channel (`RETURN_CHANCE`), so spirits remain
collectable and re-contactable, but never guaranteed. Sometimes you
call and something else picks up.
An at-peace entity (a spirit correctly helped to cross over) is
excluded from the match entirely — it stays in the Codex forever but
can't be re-contacted. `Entity.signature` is unique, so a newly minted
entity can't reuse a string a retired row still holds; it gets a salted
variant of the same base signature.
"""
signature = signature_from_anomalies(state.anomalies) or fallback_signature(
str(state.session_id)
@@ -311,19 +348,40 @@ async def _summon(state: SeanceState, channel: str) -> tuple[Entity, bool]:
for _attempt in range(2):
async with session_maker() as db:
try:
entity = await db.scalar(
known = await db.scalar(
select(Entity).where(Entity.signature == signature, Entity.at_peace.is_(False))
)
# The draw that makes contact feel like contact: even on a
# channel with a familiar presence, something else can
# answer. Domain-separated from the traits roll so the two
# are uncorrelated.
# A thinner veil (full moon, true solar midnight) makes it
# likelier that something *other* than the familiar
# presence pushes through — more traffic gets across when
# the barrier is weaker, which is the whole folkloric
# premise. Real astronomy, computed from the seeker's own
# longitude: see app/celestial.py.
sky = veil_thinness(state.longitude)
return_chance = RETURN_CHANCE * (1.0 - sky["thinness"] * VEIL_THINNESS_PULL)
answers = (
known is not None
and veil_float(state.entropy, "answers") < return_chance
)
entity = known if answers else None
is_new = entity is None
if is_new:
# A new presence on an occupied channel needs its own
# signature (the column is unique) — same salting the
# at-peace case already required.
mint_signature = signature
retired = await db.scalar(select(Entity).where(Entity.signature == signature))
if retired is not None:
taken = await db.scalar(select(Entity).where(Entity.signature == signature))
if taken is not None:
mint_signature = f"{signature}:{uuid.uuid4().hex[:8]}"
profile = await spirit_service.mint_profile(
mint_signature, channel, state.anomalies, state.language
mint_signature, channel, state.anomalies, state.language,
entropy=state.entropy,
)
discoverer = await db.get(User, state.user_id)
favor = discoverer.favor if discoverer is not None else 0.0
@@ -426,7 +484,19 @@ async def _handle_summon(state: SeanceState) -> None:
)
return
await state.send_queue.put({"type": "status", "state": "summoning"})
await state.send_queue.put(
{
"type": "status",
"state": "summoning",
# How much physical noise from the room actually fed this
# draw. Display only — a client can lie about it freely, and
# it is never used to weight or gate anything.
"entropy_bits": contribution_bits(state.entropy),
# Real astronomy, computed not fetched (app/celestial.py) — the
# seeker can see *why* tonight is different.
"sky": veil_thinness(state.longitude),
}
)
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
@@ -453,6 +523,66 @@ TELL_CHANCE_ON_FRAGMENT = 0.2
TELL_CHANCE_ON_REPLY = 0.35
# Chance that a shift in the room pulls unprompted speech through. This is
# not a reply to anything — see SpiritService.manifest(): the prompt
# contains no seeker input at all, and the token-sampling seed comes from
# physical noise measured in that room. Kept well under half so silence
# stays the norm and being spoken to unbidden stays unnerving rather than
# chatty.
MANIFEST_CHANCE_ON_ANOMALY = 0.28
def _room_readings(state: SeanceState, anomaly: dict) -> dict:
"""The measured state of the room, rendered plainly for the entity.
Values are reported as measurements, never as interpretations — the
model should react to "magnitude 31.4 dB over floor", not to
"terrifying paranormal spike". Putting the conclusion in the prompt
would mean the horror came from us instead of from the entity.
"""
readings: dict[str, str] = {}
source = anomaly.get("source")
if source:
readings["channel"] = str(source)
freq = anomaly.get("frequency")
if isinstance(freq, (int, float)):
readings["frequency"] = f"{freq:.2f}"
mag = anomaly.get("magnitude")
if isinstance(mag, (int, float)):
readings["deviation above the floor"] = f"{mag:.1f}"
readings["disturbances so far"] = str(len(state.anomalies))
sky = veil_thinness(state.longitude)
readings["moon"] = f"{sky['moon_name']} ({sky['moon_illumination']:.0%} lit)"
if sky["witching_proximity"] is not None:
readings["nearness to the dead of night"] = f"{sky['witching_proximity']:.0%}"
if state.entropy:
readings["noise gathered from the room"] = f"{contribution_bits(state.entropy)} bits"
return readings
async def _maybe_manifest(state: SeanceState, anomaly: dict) -> None:
"""Let the entity speak unbidden, if the room pulls it through."""
if state.entity is None:
return
if state.tell_rng.random() >= MANIFEST_CHANCE_ON_ANOMALY:
return
try:
text = await spirit_service.manifest(
state.entity,
_room_readings(state, anomaly),
state.language,
entropy=state.entropy,
)
except Exception:
# Deliberately broad. Unprompted speech is a bonus, never
# load-bearing — a busy queue, an unreachable Ollama, or a
# malformed response must leave the séance quiet rather than
# surfacing an error for something the seeker never asked for.
return
if text:
await _speak(state, "manifest", text)
async def _maybe_tell(state: SeanceState, chance: float) -> None:
if state.entity is None:
return
@@ -495,6 +625,7 @@ async def _handle_anomaly(state: SeanceState, message: dict) -> None:
return
await _speak(state, "fragment", fragment)
await _maybe_tell(state, TELL_CHANCE_ON_FRAGMENT)
await _maybe_manifest(state, anomaly)
async def _handle_question(state: SeanceState, text: str) -> None:
@@ -811,6 +942,21 @@ async def session_socket(websocket: WebSocket) -> None:
session.language = state.language
await db.commit()
elif msg_type == "summon":
# The seeker's room contributes its physical noise to this
# draw. Stored raw and untrusted — app/entropy.py mixes it
# with fresh server secrets on every use, so a chosen or
# replayed value can add unpredictability but never steer
# the outcome.
contributed = message.get("entropy")
if isinstance(contributed, str) and contributed.strip():
state.entropy = contributed[:512]
# Longitude only, and only if the seeker granted location.
# Out-of-range values are dropped rather than clamped: a
# bogus longitude should mean "no location", not a
# confidently wrong solar midnight.
lon = message.get("longitude")
if isinstance(lon, (int, float)) and -180 <= lon <= 180:
state.longitude = float(lon)
await _handle_summon(state)
elif msg_type == "anomaly":
await _handle_anomaly(state, message)