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

159
backend/app/celestial.py Normal file
View File

@@ -0,0 +1,159 @@
"""Real astronomy — moon phase and true solar midnight.
Every number here is computed from actual orbital mechanics, not invented
and not sampled from a table of plausible-looking values. That matters
because these feed the summon draw: if the veil is supposed to be thinner
at a full moon, the moon has to actually be full.
Two quantities:
moon_phase(when) - the synodic phase, 0=new .. 0.5=full .. 1=new.
solar_midnight(...) - the moment the sun is at its lowest for a given
longitude, i.e. the real witching hour for where
the seeker is standing rather than clock 3am.
Accuracy: the moon calculation uses the standard mean-synodic-month
approximation from a known new moon epoch. It is accurate to well under a
day over a range of centuries — far tighter than anything this app needs,
since the output is bucketed into eight named phases. Solar midnight uses
mean solar time (longitude / 15° per hour), ignoring the equation of time,
which can be off by up to ~16 minutes across the year. Both are documented
approximations rather than silent ones; neither is precise enough for
navigation and neither needs to be.
No network dependency on purpose: this must work when the box is offline,
and an API that can fail would mean the veil's behaviour silently changes
when a third party has an outage.
"""
import math
from datetime import datetime, timedelta, timezone
# Reference new moon: 2000-01-06 18:14 UTC. A widely used epoch for this
# approximation.
_KNOWN_NEW_MOON = datetime(2000, 1, 6, 18, 14, tzinfo=timezone.utc)
# Mean synodic month (new moon to new moon), in days. The moon's actual
# period varies by several hours either side of this; the mean is what the
# standard approximation uses.
SYNODIC_MONTH_DAYS = 29.530588853
# The eight conventional phase names, in order from new moon.
PHASE_NAMES = (
"new moon",
"waxing crescent",
"first quarter",
"waxing gibbous",
"full moon",
"waning gibbous",
"last quarter",
"waning crescent",
)
def moon_phase(when: datetime | None = None) -> float:
"""Synodic phase as a fraction in [0, 1).
0.0 = new moon, 0.5 = full moon. Naive datetimes are assumed UTC rather
than rejected, so a caller that forgot a tzinfo gets a slightly-off
answer instead of an exception during a summon.
"""
when = when or datetime.now(timezone.utc)
if when.tzinfo is None:
when = when.replace(tzinfo=timezone.utc)
elapsed_days = (when - _KNOWN_NEW_MOON).total_seconds() / 86400.0
return (elapsed_days % SYNODIC_MONTH_DAYS) / SYNODIC_MONTH_DAYS
def moon_phase_name(phase: float) -> str:
"""Bucket a phase fraction into one of the eight conventional names.
Buckets are centred on their phase (the "full moon" bucket straddles
0.5) rather than starting at it, which is why the +1/16 offset is
there — without it every name would be shifted half a bucket late.
"""
index = int(((phase + 1 / 16) % 1.0) * 8) % 8
return PHASE_NAMES[index]
def moon_illumination(phase: float) -> float:
"""Fraction of the disc lit, 0.0 (new) .. 1.0 (full).
Follows the standard cosine relation to the phase angle; this is the
same curve that makes a quarter moon look half-lit.
"""
return (1 - math.cos(2 * math.pi * phase)) / 2
def solar_midnight(longitude_deg: float, when: datetime | None = None) -> datetime:
"""The UTC instant of *mean* solar midnight nearest `when` for a longitude.
Mean solar time only: the equation of time (up to ~16 minutes) is not
applied. Good enough to know whether the seeker is near the true dead
of night for where they are standing, which is all this is used for.
"""
when = when or datetime.now(timezone.utc)
if when.tzinfo is None:
when = when.replace(tzinfo=timezone.utc)
# Every 15° of longitude shifts local solar time by one hour.
offset_hours = longitude_deg / 15.0
local = when + timedelta(hours=offset_hours)
midnight_local = local.replace(hour=0, minute=0, second=0, microsecond=0)
# Pick whichever midnight (today's or tomorrow's) is actually closest,
# so 23:50 local resolves to the midnight ten minutes ahead rather than
# the one nearly a day behind.
if local.hour >= 12:
midnight_local += timedelta(days=1)
return midnight_local - timedelta(hours=offset_hours)
def witching_proximity(longitude_deg: float, when: datetime | None = None) -> float:
"""How close the seeker is to their own solar midnight, 0.0 .. 1.0.
1.0 exactly at solar midnight, falling to 0.0 twelve hours away. Used
to weight the veil rather than to gate it — there is no hour at which
contact is impossible, only hours at which it is thinner.
"""
when = when or datetime.now(timezone.utc)
if when.tzinfo is None:
when = when.replace(tzinfo=timezone.utc)
midnight = solar_midnight(longitude_deg, when)
hours_away = abs((when - midnight).total_seconds()) / 3600.0
# solar_midnight returns the nearest one, so this is already <= 12.
return max(0.0, 1.0 - min(hours_away, 12.0) / 12.0)
def veil_thinness(
longitude_deg: float | None = None, when: datetime | None = None
) -> dict:
"""Combined 'how thin is the veil right now' reading.
Moon illumination and (when a longitude is known) proximity to true
solar midnight, blended into a single 0..1 figure plus the components
that produced it, so the UI can explain *why* rather than showing a
bare number.
Without a longitude the reading is moon-only — a seeker who declines
location still gets a real celestial contribution, just a coarser one.
"""
when = when or datetime.now(timezone.utc)
phase = moon_phase(when)
illumination = moon_illumination(phase)
if longitude_deg is None:
thinness = illumination
witching = None
else:
witching = witching_proximity(longitude_deg, when)
# Weighted toward the hour: standing in the dead of night matters
# more than the moon being full, and a full moon at noon should
# not read as "the veil is wide open".
thinness = illumination * 0.4 + witching * 0.6
return {
"moon_phase": phase,
"moon_name": moon_phase_name(phase),
"moon_illumination": illumination,
"witching_proximity": witching,
"thinness": max(0.0, min(1.0, thinness)),
}

View File

@@ -6,6 +6,7 @@ import hashlib
import json import json
import random import random
from app.entropy import veil_random
from app.models.entity import RARITY_TIERS from app.models.entity import RARITY_TIERS
from app.tts.voices import EN_VOICE_IDS from app.tts.voices import EN_VOICE_IDS
@@ -96,14 +97,27 @@ def parse_mint_response(text: str) -> dict | None:
return profile return profile
def roll_traits(signature: str) -> dict: def roll_traits(signature: str, entropy: object = None) -> dict:
"""Roll the four hidden truth-traits for an entity, seeded from its """Roll the four hidden truth-traits for an entity. These are never
signature. These are never derived from — or fed into — the LLM persona derived from — or fed into — the LLM persona prompt (see `mint_prompt`,
prompt (see `mint_prompt`, which never sees this function's output): which never sees this function's output): persona text must stay fully
persona text must stay fully decoupled from ground truth, so a decoupled from ground truth, so a convincing "sweet old lady" persona
convincing "sweet old lady" persona can pair with any alignment roll. can pair with any alignment roll.
With `entropy` (a physical-noise contribution from the seeker's room —
see app/entropy.py), the roll is genuinely unpredictable: what answers
is decided by the room, not by a hash. Without it, the roll stays
signature-deterministic, which is what the pure-function tests and
`judgment`'s favor-bias comparisons rely on.
A separate `random.Random` namespace (`"traits:"` vs. `normalize_profile`'s A separate `random.Random` namespace (`"traits:"` vs. `normalize_profile`'s
`"norm:"`) keeps this roll independent of the cosmetic-defaults rng.""" `"norm:"`) keeps this roll independent of the cosmetic-defaults rng."""
if entropy is not None:
# Domain-separated from the "which entity answers" draw so the two
# can't be correlated — learning an entity's rarity must reveal
# nothing about its hidden alignment.
rng = veil_random(entropy, f"traits:{signature}")
else:
rng = random.Random(f"traits:{signature}") rng = random.Random(f"traits:{signature}")
return { return {
"alignment": rng.uniform(0.0, 1.0), "alignment": rng.uniform(0.0, 1.0),
@@ -113,10 +127,19 @@ def roll_traits(signature: str) -> dict:
} }
def normalize_profile(profile: dict, signature: str) -> dict: def normalize_profile(profile: dict, signature: str, entropy: object = None) -> dict:
"""Coerce an LLM (or fallback) profile into the exact shape the DB and """Coerce an LLM (or fallback) profile into the exact shape the DB and
frontend expect, filling gaps with signature-deterministic defaults.""" frontend expect, filling gaps with defaults.
rng = random.Random(f"norm:{signature}")
With `entropy`, the gap-filling defaults (voice, hue, form) are drawn
from physical noise, so two spirits minted on the same channel don't
inherit identical throats and colours. Without it, defaults stay
signature-deterministic for the pure-function tests."""
rng = (
veil_random(entropy, f"norm:{signature}")
if entropy is not None
else random.Random(f"norm:{signature}")
)
voice = profile.get("voice") if isinstance(profile.get("voice"), dict) else {} voice = profile.get("voice") if isinstance(profile.get("voice"), dict) else {}
visual = profile.get("visual") if isinstance(profile.get("visual"), dict) else {} visual = profile.get("visual") if isinstance(profile.get("visual"), dict) else {}
quotes = profile.get("quotes") if isinstance(profile.get("quotes"), list) else [] quotes = profile.get("quotes") if isinstance(profile.get("quotes"), list) else []
@@ -155,14 +178,23 @@ def normalize_profile(profile: dict, signature: str) -> dict:
}, },
"quotes": [str(q)[:200] for q in quotes[:4] if isinstance(q, str)] "quotes": [str(q)[:200] for q in quotes[:4] if isinstance(q, str)]
or rng.sample(_QUOTE_BANK, 2), or rng.sample(_QUOTE_BANK, 2),
"traits": roll_traits(signature), "traits": roll_traits(signature, entropy),
} }
def fallback_profile(signature: str) -> dict: def fallback_profile(signature: str, entropy: object = None) -> dict:
"""Procedural persona for when the LLM box is unreachable — a summoning """Procedural persona for when the LLM box is unreachable — a summoning
must never visibly fail.""" must never visibly fail.
rng = random.Random(f"fallback:{signature}")
With `entropy`, even the fallback spirits differ run to run: with the
LLM down, a purely signature-seeded fallback would hand every seeker on
a given channel the identical name, epithet and rarity, which is
exactly the "example data" feel this is meant to avoid."""
rng = (
veil_random(entropy, f"fallback:{signature}")
if entropy is not None
else random.Random(f"fallback:{signature}")
)
name = rng.choice(_NAME_PARTS[0]) + rng.choice(_NAME_PARTS[1]) name = rng.choice(_NAME_PARTS[0]) + rng.choice(_NAME_PARTS[1])
epithet = rng.choice(_EPITHETS) epithet = rng.choice(_EPITHETS)
persona = rng.choice(_PERSONA_TEMPLATES).format(name=name) persona = rng.choice(_PERSONA_TEMPLATES).format(name=name)
@@ -176,4 +208,5 @@ def fallback_profile(signature: str) -> dict:
"quotes": rng.sample(_QUOTE_BANK, 2), "quotes": rng.sample(_QUOTE_BANK, 2),
}, },
signature, signature,
entropy,
) )

110
backend/app/entropy.py Normal file
View File

@@ -0,0 +1,110 @@
"""The veil's randomness — mixing physical entropy from the seeker's room
with server-side secrets.
Design premise: contact should be genuinely unpredictable, and the
unpredictability should come from the physical world the seeker is
standing in (their microphone's noise floor, the RF noise between
stations, sensor jitter) rather than from a deterministic hash of their
session. Before this module, `_summon` derived everything from
`signature_from_anomalies()` — a SHA-1 of the anomaly pattern — which meant
identical conditions always produced an identical spirit. That is the
opposite of channeling.
SECURITY — why client entropy is never used alone:
The client is untrusted. A malicious seeker could send a fixed
"entropy" string and re-roll until they hit a mythic entity, or one
with traits they want, grinding the rarity table and the drop economy.
So client contributions are only ever *mixed in*, never used as the
seed. Every draw is HMAC-SHA256(server_secret_bytes, client_bytes ||
context), where the server bytes come from `secrets.token_bytes()` on
every single call. Because a fresh cryptographically-secure server
contribution is always present, the output is unpredictable and
uniformly distributed *no matter what the client sends* — including
all-zeros, a replayed value, or a value chosen adversarially.
The client's contribution therefore can only ever *add* unpredictability
from the room; it can never subtract any or steer the result. That is
exactly the property we want: the physical world genuinely participates,
but it cannot be forged into an advantage.
This mirrors how real hardware RNGs are used: physical noise is a source
that gets conditioned and mixed into a CSPRNG, never trusted raw.
"""
import hashlib
import hmac
import random
import secrets
# A client contribution is a SHA-256 hex digest (see frontend
# lib/entropy.ts). Anything longer is truncated rather than rejected, so a
# future client that sends a larger pool still works; anything that isn't
# valid hex is discarded entirely rather than silently coerced.
MAX_CONTRIBUTION_CHARS = 512
def normalize_contribution(raw: object) -> bytes:
"""Coerce whatever the client sent into bytes worth mixing.
Returns empty bytes for anything unusable. Empty is completely safe —
the server contribution alone still produces a strong draw — so this
never needs to raise, and a malformed payload degrades to "no physical
entropy this time" rather than failing the summon.
"""
if not isinstance(raw, str):
return b""
text = raw.strip()[:MAX_CONTRIBUTION_CHARS]
if not text:
return b""
try:
return bytes.fromhex(text)
except ValueError:
# Not hex — still mix it as UTF-8 rather than throwing it away.
# It cannot hurt (see the security note above) and a client with a
# different encoding still contributes real noise.
return text.encode("utf-8", "ignore")
def veil_seed(contribution: object = None, context: str = "") -> bytes:
"""One unpredictable 32-byte seed.
`context` domain-separates independent draws made from the same
contribution (e.g. "which entity" vs. "what traits"), so they can't be
correlated with each other.
"""
client_bytes = normalize_contribution(contribution)
# Fresh server entropy on every call — this is what makes the result
# unpredictable regardless of client behaviour.
server_bytes = secrets.token_bytes(32)
return hmac.new(
server_bytes,
client_bytes + b"|" + context.encode("utf-8", "ignore"),
hashlib.sha256,
).digest()
def veil_random(contribution: object = None, context: str = "") -> random.Random:
"""A `random.Random` seeded from mixed physical + server entropy.
Returned rather than a raw int so callers keep using the ordinary
random API (`.random()`, `.choice()`, `.gauss()`) they already use with
signature-seeded generators, making this a drop-in replacement at every
existing call site.
"""
return random.Random(veil_seed(contribution, context))
def veil_float(contribution: object = None, context: str = "") -> float:
"""A single unpredictable float in [0, 1)."""
return veil_random(contribution, context).random()
def contribution_bits(raw: object) -> int:
"""How many bits of physical entropy the client actually supplied.
Used only for display ("the air is thick") and telemetry — never to
gate or weight the draw, since a client can lie about it freely.
"""
return len(normalize_contribution(raw)) * 8

View File

@@ -35,6 +35,34 @@ CHAT_SYSTEM = (
"{language_clause}" "{language_clause}"
) )
MANIFEST_SYSTEM = (
"You are {name}, {epithet} — a spirit persona in an interactive horror "
"art installation.\n"
"Your nature: {persona}\n"
"NOBODY HAS ASKED YOU ANYTHING. No question is coming. You are not "
"answering, replying, greeting, or responding — you are intruding. "
"Something in the room moved and it pulled a fragment of you through "
"with it.\n"
"Say the thing that surfaces. It may be mid-sentence. It may not make "
"sense to anyone living. It may be a name, a number, a warning, a "
"complaint, an instruction to someone who is not there, or a piece of "
"an argument you were having when you died. Do not explain it. Do not "
"address the listener unless you mistake them for someone else.\n"
"Under 18 words. Never break character, never mention being an AI."
"{language_clause}"
)
# What the entity is given instead of a question: the physical state of the
# room, as measured. There is deliberately no seeker input anywhere in this
# prompt — the model has nothing to reply *to*, only something to speak
# *from*. That is the whole point of this path existing separately from
# chat_prompt().
MANIFEST_PROMPT = """The room right now:
{readings}
Something shifted. Speak."""
MINT_SYSTEM = ( MINT_SYSTEM = (
"You invent spirit personas for an interactive horror art installation. " "You invent spirit personas for an interactive horror art installation. "
"The single biggest thing separating a convincing dead person from a " "The single biggest thing separating a convincing dead person from a "
@@ -137,6 +165,31 @@ def chat_prompt(question: str, history: list[dict]) -> str:
return "\n".join(lines) return "\n".join(lines)
def manifest_system(entity: dict, language: str = "en") -> str:
return MANIFEST_SYSTEM.format(
name=entity.get("name", "an unnamed presence"),
epithet=entity.get("epithet", "a voice in the static"),
persona=entity.get("persona", "A drifting presence with no remembered past."),
language_clause=language_clause(language),
)
def manifest_prompt(readings: dict) -> str:
"""Render measured room state as the entity's only stimulus.
Values are rendered plainly and without interpretation — "magnetic
field 61.2 uT (up 13.1)" rather than "strong paranormal activity" — so
the model is reacting to a measurement rather than being told what to
conclude. Telling it "the haunting is intense" would put the horror in
the prompt; showing it a number lets the horror come from the entity.
"""
if not readings:
lines = ["nothing measurable. only the dark."]
else:
lines = [f"- {label}: {value}" for label, value in readings.items()]
return MANIFEST_PROMPT.format(readings="\n".join(lines))
def mint_prompt( def mint_prompt(
signature: str, channel: str, anomaly_summary: str, voice_ids: list[str] signature: str, channel: str, anomaly_summary: str, voice_ids: list[str]
) -> str: ) -> str:

View File

@@ -13,6 +13,7 @@ import httpx
from app import entities from app import entities
from app.config import settings from app.config import settings
from app.entropy import veil_seed
from app.llm import prompts from app.llm import prompts
from app.llm.client import OllamaClient from app.llm.client import OllamaClient
from app.llm.queue import LLMQueue, QueueFullError from app.llm.queue import LLMQueue, QueueFullError
@@ -132,14 +133,72 @@ class SpiritService:
except (httpx.HTTPError, KeyError, ValueError): except (httpx.HTTPError, KeyError, ValueError):
yield random.choice(FALLBACK_REPLIES) yield random.choice(FALLBACK_REPLIES)
async def manifest(
self,
entity: dict,
readings: dict,
language: str = "en",
entropy: object = None,
) -> str:
"""Unprompted speech — the entity says something nobody asked for.
This is deliberately NOT chat_stream with an empty question. Two
things make it generation *from* something rather than a reply
*to* something:
1. The prompt contains no seeker input at all. The model's only
stimulus is the measured state of the room (see
prompts.manifest_prompt), so there is nothing to answer.
2. The sampling seed is derived from physical entropy harvested in
that room — the microphone's noise floor, RF noise, magnetometer
jitter. Ollama's `seed` option fixes the token-sampling path, so
seeding it from real physical noise means the room genuinely
selects the words. Not a metaphor: change the noise, get
different speech, and no two rooms produce the same utterance.
High temperature and top_k on purpose — a tight, "correct" decode
produces a well-behaved assistant sentence, which is exactly the
failure mode here. This should sound like something surfacing, not
something composed.
"""
# 63-bit: Ollama takes a signed 64-bit seed, and staying under the
# sign bit avoids any wraparound surprises across versions.
seed = int.from_bytes(veil_seed(entropy, "manifest")[:8], "big") % (2**63)
async def call() -> str:
return await self._client.generate(
settings.ollama_chat_model,
prompts.manifest_prompt(readings),
system=prompts.manifest_system(entity, language),
options={
"num_predict": 40,
"temperature": 1.15,
"top_k": 100,
"top_p": 0.98,
"repeat_penalty": 1.05,
"seed": seed,
},
)
raw = await self._queue.submit(call)
self._touch()
return raw.strip().strip('"')[:200]
async def mint_profile( async def mint_profile(
self, self,
signature: str, signature: str,
channel: str, channel: str,
anomalies: list[dict], anomalies: list[dict],
language: str = "en", language: str = "en",
entropy: object = None,
) -> dict: ) -> dict:
"""Invent a full persona for a new signature, normalized to schema.""" """Invent a full persona for a new signature, normalized to schema.
`entropy` is the seeker's physical-noise contribution (see
app/entropy.py); it drives the hidden trait roll and any cosmetic
defaults the LLM left unfilled, so two spirits minted on the same
channel are genuinely different rather than identical."""
summary = json.dumps(anomalies[-10:])[:600] summary = json.dumps(anomalies[-10:])[:600]
voice_ids = ES_VOICE_IDS if language == "es" else EN_VOICE_IDS voice_ids = ES_VOICE_IDS if language == "es" else EN_VOICE_IDS
@@ -156,10 +215,10 @@ class SpiritService:
self._touch() self._touch()
profile = entities.parse_mint_response(raw) profile = entities.parse_mint_response(raw)
if profile is None: if profile is None:
return entities.fallback_profile(signature) return entities.fallback_profile(signature, entropy)
return entities.normalize_profile(profile, signature) return entities.normalize_profile(profile, signature, entropy)
except (QueueFullError, httpx.HTTPError, KeyError, ValueError): except (QueueFullError, httpx.HTTPError, KeyError, ValueError):
return entities.fallback_profile(signature) return entities.fallback_profile(signature, entropy)
# The app-wide instance; tests monkeypatch this. # The app-wide instance; tests monkeypatch this.

View File

@@ -36,6 +36,8 @@ from app.config import settings
from app.db import async_session_maker as _default_session_maker from app.db import async_session_maker as _default_session_maker
from app.deps import SESSION_COOKIE_NAME from app.deps import SESSION_COOKIE_NAME
from app.entities import fallback_signature, signature_from_anomalies 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 ( from app.inventory import (
RITUAL_SUCCESS_ESSENCE, RITUAL_SUCCESS_ESSENCE,
SUMMON_ESSENCE_TRICKLE, 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) ritual_ip_limiter = RateLimiter(max_requests=12, window_seconds=60)
judgment_ip_limiter = RateLimiter(max_requests=20, 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" 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 # vary run to run), but persistent across calls so the draw sequence
# isn't restarted on every single message. # isn't restarted on every single message.
tell_rng: random.Random = field(default_factory=random.Random) 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 # 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]: 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 The signature identifies a *channel*, not a spirit. Whether the entity
over) is excluded from the match — it stays in the Codex forever but previously reached on this channel answers again, or something else
can't be re-contacted. If its signature is what this session's anomaly manifests instead, is a genuine draw against physical entropy harvested
pattern hashes to, a *new* entity is minted instead. `Entity.signature` from the seeker's room (app/entropy.py) — not a deterministic lookup.
is unique, so the new entity can't reuse the exact same string while the Before this, an identical anomaly pattern always produced an identical
retired row still holds it — it gets a salted variant of the same base spirit, which made contact feel like a database query rather than
signature instead. 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( signature = signature_from_anomalies(state.anomalies) or fallback_signature(
str(state.session_id) str(state.session_id)
@@ -311,19 +348,40 @@ async def _summon(state: SeanceState, channel: str) -> tuple[Entity, bool]:
for _attempt in range(2): for _attempt in range(2):
async with session_maker() as db: async with session_maker() as db:
try: try:
entity = await db.scalar( known = await db.scalar(
select(Entity).where(Entity.signature == signature, Entity.at_peace.is_(False)) 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 is_new = entity is None
if is_new: 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 mint_signature = signature
retired = await db.scalar(select(Entity).where(Entity.signature == signature)) taken = await db.scalar(select(Entity).where(Entity.signature == signature))
if retired is not None: if taken is not None:
mint_signature = f"{signature}:{uuid.uuid4().hex[:8]}" mint_signature = f"{signature}:{uuid.uuid4().hex[:8]}"
profile = await spirit_service.mint_profile( 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) discoverer = await db.get(User, state.user_id)
favor = discoverer.favor if discoverer is not None else 0.0 favor = discoverer.favor if discoverer is not None else 0.0
@@ -426,7 +484,19 @@ async def _handle_summon(state: SeanceState) -> None:
) )
return 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") entity, is_new = await _summon(state, state.mode if state.mode != "unknown" else "ouija")
state.entity = serialize_entity(entity) state.entity = serialize_entity(entity)
# A fresh presence invalidates any in-progress/completed ritual from # 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 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: async def _maybe_tell(state: SeanceState, chance: float) -> None:
if state.entity is None: if state.entity is None:
return return
@@ -495,6 +625,7 @@ async def _handle_anomaly(state: SeanceState, message: dict) -> None:
return return
await _speak(state, "fragment", fragment) await _speak(state, "fragment", fragment)
await _maybe_tell(state, TELL_CHANCE_ON_FRAGMENT) await _maybe_tell(state, TELL_CHANCE_ON_FRAGMENT)
await _maybe_manifest(state, anomaly)
async def _handle_question(state: SeanceState, text: str) -> None: async def _handle_question(state: SeanceState, text: str) -> None:
@@ -811,6 +942,21 @@ async def session_socket(websocket: WebSocket) -> None:
session.language = state.language session.language = state.language
await db.commit() await db.commit()
elif msg_type == "summon": 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) await _handle_summon(state)
elif msg_type == "anomaly": elif msg_type == "anomaly":
await _handle_anomaly(state, message) await _handle_anomaly(state, message)

View File

@@ -0,0 +1,176 @@
"""Tests for the real-astronomy layer.
These check the maths against genuinely known astronomical events rather
than against the implementation's own output — a test that only asserts
"the function returns what the function returns" would happily pass on
completely wrong orbital mechanics.
"""
from datetime import datetime, timedelta, timezone
import pytest
from app.celestial import (
PHASE_NAMES,
SYNODIC_MONTH_DAYS,
moon_illumination,
moon_phase,
moon_phase_name,
solar_midnight,
veil_thinness,
witching_proximity,
)
def _utc(y, m, d, hh=0, mm=0):
return datetime(y, m, d, hh, mm, tzinfo=timezone.utc)
class TestMoonPhase:
def test_epoch_new_moon_reads_as_new(self):
# The reference epoch itself must land on ~0.
assert moon_phase(_utc(2000, 1, 6, 18, 14)) == pytest.approx(0.0, abs=0.01)
def test_known_full_moons_read_as_full(self):
# Real full moons, from published ephemerides. Tolerance is ~half a
# day in phase terms, which is what the mean-synodic approximation
# honestly supports.
for when in (
_utc(2024, 1, 25, 17, 54),
_utc(2024, 8, 19, 18, 26),
_utc(2025, 3, 14, 6, 55),
):
assert moon_phase(when) == pytest.approx(0.5, abs=0.04), when
def test_known_new_moons_read_as_new(self):
for when in (
_utc(2024, 2, 9, 22, 59),
_utc(2024, 9, 3, 1, 56),
_utc(2025, 3, 29, 10, 58),
):
phase = moon_phase(when)
# New moon sits at the 0/1 wraparound, so accept either end.
assert min(phase, 1 - phase) == pytest.approx(0.0, abs=0.04), when
def test_phase_always_in_unit_range(self):
when = _utc(2024, 1, 1)
for i in range(400):
p = moon_phase(when + timedelta(days=i))
assert 0.0 <= p < 1.0
def test_advances_a_full_cycle_over_one_synodic_month(self):
start = _utc(2024, 6, 1)
later = start + timedelta(days=SYNODIC_MONTH_DAYS)
assert moon_phase(start) == pytest.approx(moon_phase(later), abs=0.001)
def test_naive_datetime_is_treated_as_utc_rather_than_raising(self):
# A summon must never fail because a caller forgot a tzinfo.
naive = datetime(2024, 1, 25, 17, 54)
assert moon_phase(naive) == pytest.approx(0.5, abs=0.04)
class TestPhaseNames:
def test_new_and_full_map_to_the_right_names(self):
assert moon_phase_name(0.0) == "new moon"
assert moon_phase_name(0.5) == "full moon"
def test_quarters_map_to_the_right_names(self):
assert moon_phase_name(0.25) == "first quarter"
assert moon_phase_name(0.75) == "last quarter"
def test_every_phase_yields_a_known_name(self):
for i in range(100):
assert moon_phase_name(i / 100) in PHASE_NAMES
def test_names_progress_in_order_across_a_cycle(self):
seen = []
for i in range(64):
name = moon_phase_name(i / 64)
if not seen or seen[-1] != name:
seen.append(name)
# Starts and ends on "new moon" (the cycle wraps), covering all 8.
assert set(seen) == set(PHASE_NAMES)
class TestIllumination:
def test_new_moon_is_dark_and_full_moon_is_lit(self):
assert moon_illumination(0.0) == pytest.approx(0.0, abs=1e-9)
assert moon_illumination(0.5) == pytest.approx(1.0, abs=1e-9)
def test_quarters_are_half_lit(self):
assert moon_illumination(0.25) == pytest.approx(0.5, abs=1e-9)
assert moon_illumination(0.75) == pytest.approx(0.5, abs=1e-9)
def test_always_in_unit_range(self):
for i in range(200):
v = moon_illumination(i / 200)
assert 0.0 <= v <= 1.0
class TestSolarMidnight:
def test_greenwich_midnight_is_utc_midnight(self):
got = solar_midnight(0.0, _utc(2024, 6, 15, 23, 0))
assert got.hour == 0 and got.minute == 0
def test_longitude_shifts_midnight_by_an_hour_per_15_degrees(self):
at_zero = solar_midnight(0.0, _utc(2024, 6, 15, 12, 0))
at_fifteen_east = solar_midnight(15.0, _utc(2024, 6, 15, 12, 0))
delta_hours = (at_zero - at_fifteen_east).total_seconds() / 3600
assert delta_hours == pytest.approx(1.0, abs=0.01)
def test_returns_the_nearest_midnight_not_a_stale_one(self):
# Just before local midnight the answer must be the one ahead,
# never the one ~24h behind.
when = _utc(2024, 6, 15, 23, 50)
assert abs((solar_midnight(0.0, when) - when).total_seconds()) < 3600
class TestWitchingProximity:
def test_peaks_at_solar_midnight(self):
when = _utc(2024, 6, 15, 0, 0)
assert witching_proximity(0.0, when) == pytest.approx(1.0, abs=0.01)
def test_bottoms_out_at_solar_noon(self):
when = _utc(2024, 6, 15, 12, 0)
assert witching_proximity(0.0, when) == pytest.approx(0.0, abs=0.01)
def test_always_in_unit_range_around_the_clock(self):
base = _utc(2024, 6, 15)
for hour in range(48):
v = witching_proximity(0.0, base + timedelta(hours=hour))
assert 0.0 <= v <= 1.0
def test_accounts_for_the_seekers_longitude(self):
# 03:00 UTC is the dead of night at Greenwich but not in Tokyo.
when = _utc(2024, 6, 15, 3, 0)
assert witching_proximity(0.0, when) > witching_proximity(139.7, when)
class TestVeilThinness:
def test_reports_all_components(self):
r = veil_thinness(0.0, _utc(2024, 1, 25, 0, 0))
assert set(r) == {
"moon_phase",
"moon_name",
"moon_illumination",
"witching_proximity",
"thinness",
}
def test_moon_only_when_location_is_unknown(self):
r = veil_thinness(None, _utc(2024, 1, 25, 17, 54))
assert r["witching_proximity"] is None
# A full moon with no location should still read as thin.
assert r["thinness"] > 0.9
def test_full_moon_at_solar_midnight_is_thinner_than_new_moon_at_noon(self):
best = veil_thinness(0.0, _utc(2024, 1, 25, 0, 0))
worst = veil_thinness(0.0, _utc(2024, 2, 9, 12, 0))
assert best["thinness"] > worst["thinness"]
def test_thinness_always_in_unit_range(self):
base = _utc(2024, 1, 1)
for i in range(0, 400, 7):
for lon in (-180.0, -75.0, 0.0, 139.7, 180.0):
v = veil_thinness(lon, base + timedelta(days=i, hours=i % 24))
assert 0.0 <= v["thinness"] <= 1.0

View File

@@ -0,0 +1,117 @@
"""Tests for the veil's randomness mixing.
The property that actually matters here is adversarial: a client that
controls its entropy contribution completely must not be able to control,
predict, or bias the outcome. Most of these tests attack that directly
rather than just checking the happy path.
"""
import collections
from app.entropy import (
contribution_bits,
normalize_contribution,
veil_float,
veil_random,
veil_seed,
)
class TestNormalizeContribution:
def test_parses_a_hex_digest(self):
assert normalize_contribution("00ff10") == b"\x00\xff\x10"
def test_empty_for_non_string_input(self):
for junk in (None, 123, {"a": 1}, [1, 2], b"bytes"):
assert normalize_contribution(junk) == b""
def test_empty_for_blank_string(self):
assert normalize_contribution("") == b""
assert normalize_contribution(" ") == b""
def test_non_hex_still_contributes_rather_than_being_discarded(self):
# A client with a different encoding shouldn't silently stop
# contributing physical noise; mixing raw text is harmless.
assert normalize_contribution("not-hex-at-all") != b""
def test_truncates_an_oversized_payload(self):
huge = "ab" * 10_000
assert len(normalize_contribution(huge)) <= 512
def test_never_raises_on_hostile_input(self):
for junk in ("zz", "0", "0x1234", "\x00\x01", "💀" * 50, "-1"):
normalize_contribution(junk) # must not raise
class TestVeilSeed:
def test_returns_32_bytes(self):
assert len(veil_seed("aabb")) == 32
def test_identical_input_produces_different_seeds(self):
# THE core security property: the same client contribution must
# NOT reproduce the same draw, or a seeker could replay a
# contribution that once yielded a mythic entity.
seeds = {veil_seed("deadbeef").hex() for _ in range(200)}
assert len(seeds) == 200
def test_unpredictable_even_with_no_contribution_at_all(self):
seeds = {veil_seed().hex() for _ in range(200)}
assert len(seeds) == 200
def test_unpredictable_with_an_adversarially_degenerate_contribution(self):
# All-zeros is the worst case a client can send.
seeds = {veil_seed("00" * 32).hex() for _ in range(200)}
assert len(seeds) == 200
def test_context_domain_separates_draws(self):
# Two draws from one contribution must not be correlated, so
# learning one (e.g. the visible rarity) reveals nothing about the
# other (the hidden traits).
a = {veil_seed("aabb", "entity").hex() for _ in range(100)}
b = {veil_seed("aabb", "traits").hex() for _ in range(100)}
assert not (a & b)
class TestVeilRandom:
def test_returns_a_usable_random_instance(self):
rng = veil_random("aabb")
assert 0.0 <= rng.random() < 1.0
assert rng.choice([1, 2, 3]) in (1, 2, 3)
def test_successive_calls_are_independent(self):
first = [veil_random("same").random() for _ in range(50)]
assert len(set(first)) == 50
def test_client_cannot_force_a_repeated_outcome(self):
# Simulates a seeker replaying one contribution to grind for a rare
# result: the distribution must stay spread out.
draws = [veil_random("c0ffee", "rarity").random() for _ in range(400)]
assert len(set(draws)) == 400
buckets = collections.Counter(int(d * 4) for d in draws)
# With 400 draws across 4 buckets, a client steering the result
# would show up as a badly skewed histogram.
for count in buckets.values():
assert 40 < count < 210
def test_output_is_roughly_uniform(self):
draws = [veil_float() for _ in range(2000)]
buckets = collections.Counter(int(d * 10) for d in draws)
assert len(buckets) == 10
for count in buckets.values():
assert 120 < count < 290 # ~200 expected, generous bounds
def test_mean_is_near_a_half(self):
draws = [veil_float("aabb", "spread") for _ in range(2000)]
assert 0.45 < sum(draws) / len(draws) < 0.55
class TestContributionBits:
def test_counts_bits_of_real_contribution(self):
assert contribution_bits("00ff") == 16
assert contribution_bits("") == 0
assert contribution_bits(None) == 0
def test_a_lying_client_cannot_inflate_beyond_the_cap(self):
# Display-only, but it still must not become an unbounded number
# driven by client input.
assert contribution_bits("ab" * 10_000) <= 512 * 8

View File

@@ -21,7 +21,7 @@ from app.rate_limit import RateLimiter
class FakeSpiritService: class FakeSpiritService:
async def mint_profile(self, signature, channel, anomalies, language="en"): async def mint_profile(self, signature, channel, anomalies, language="en", entropy=None):
return fallback_profile(signature) return fallback_profile(signature)
async def fragment(self, source, anomaly, language="en"): async def fragment(self, source, anomaly, language="en"):

View File

@@ -15,7 +15,7 @@ from app.rate_limit import RateLimiter
class FakeSpiritService: class FakeSpiritService:
async def mint_profile(self, signature, channel, anomalies, language="en"): async def mint_profile(self, signature, channel, anomalies, language="en", entropy=None):
return fallback_profile(signature) return fallback_profile(signature)
async def fragment(self, source, anomaly, language="en"): async def fragment(self, source, anomaly, language="en"):
@@ -160,7 +160,24 @@ async def test_anomalies_attune_then_produce_fragments(sync_client):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_same_signature_recontacts_same_entity(sync_client): async def test_familiar_presence_answers_again_on_a_known_channel(sync_client, monkeypatch):
"""The Codex mechanic: a channel's known spirit is re-contactable.
Whether it answers is a genuine draw against physical entropy
(`ws.RETURN_CHANCE`), so this pins the probability to 1.0 rather than
relying on the default — at 0.72 this assertion would otherwise pass
only ~72% of the time, which is worse than failing.
"""
# Scoped limiters: the module-level ones are shared singletons that
# accumulate across the whole session, and this test summons more than
# once. Without this it silently eats the per-IP budget that
# test_summon_rate_limited_* depends on, making *those* tests hang
# waiting for an entity frame that was rate-limited away.
monkeypatch.setattr(app.ws, "summon_limiter", RateLimiter(max_requests=100, window_seconds=60))
monkeypatch.setattr(
app.ws, "summon_ip_limiter", RateLimiter(max_requests=100, window_seconds=60)
)
monkeypatch.setattr(app.ws, "RETURN_CHANCE", 1.0)
_login(sync_client, "mediumx") _login(sync_client, "mediumx")
anomalies = [ anomalies = [
{"type": "anomaly", "source": "radio", "frequency": 101.0 + i, "magnitude": 5.0 + i} {"type": "anomaly", "source": "radio", "frequency": 101.0 + i, "magnitude": 5.0 + i}
@@ -183,6 +200,48 @@ async def test_same_signature_recontacts_same_entity(sync_client):
assert second_frame["entity"]["contact_count"] == 2 assert second_frame["entity"]["contact_count"] == 2
@pytest.mark.asyncio
async def test_something_else_can_answer_a_known_channel(sync_client, monkeypatch):
"""The point of the entropy rework: contact is not a database lookup.
With the return draw forced to fail, calling into a channel that
already holds a spirit mints a *different* one rather than handing back
the same row — and the newcomer gets its own signature, since the
column is unique and the original still holds the base string.
"""
# Scoped limiters: the module-level ones are shared singletons that
# accumulate across the whole session, and this test summons more than
# once. Without this it silently eats the per-IP budget that
# test_summon_rate_limited_* depends on, making *those* tests hang
# waiting for an entity frame that was rate-limited away.
monkeypatch.setattr(app.ws, "summon_limiter", RateLimiter(max_requests=100, window_seconds=60))
monkeypatch.setattr(
app.ws, "summon_ip_limiter", RateLimiter(max_requests=100, window_seconds=60)
)
_login(sync_client, "channel-crosser")
anomalies = [
{"type": "anomaly", "source": "radio", "frequency": 88.0 + i, "magnitude": 9.0 + i}
for i in range(4)
]
monkeypatch.setattr(app.ws, "RETURN_CHANCE", 1.0)
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
_read_until(ws, "session")
for anomaly in anomalies:
ws.send_json(anomaly)
first = _read_until(ws, "entity")["entity"]
# Same room, same anomalies — but this time nothing familiar picks up.
monkeypatch.setattr(app.ws, "RETURN_CHANCE", 0.0)
with _ws_connect(sync_client, sync_client.cookies.get("qm_session")) as ws:
_read_until(ws, "session")
for anomaly in anomalies:
ws.send_json(anomaly)
second = _read_until(ws, "entity")
assert second["is_new"] is True
assert second["entity"]["id"] != first["id"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_summon_rate_limited_per_account(sync_client, monkeypatch): async def test_summon_rate_limited_per_account(sync_client, monkeypatch):
# Swap in a tight, test-scoped limiter so this doesn't depend on (or # Swap in a tight, test-scoped limiter so this doesn't depend on (or
@@ -320,7 +379,7 @@ async def test_summon_high_rarity_item_drop_is_persisted_and_sent(
monkeypatch.setattr(app.ws, "summon_limiter", RateLimiter(max_requests=1000, window_seconds=60)) monkeypatch.setattr(app.ws, "summon_limiter", RateLimiter(max_requests=1000, window_seconds=60))
class MythicSpiritService: class MythicSpiritService:
async def mint_profile(self, signature, channel, anomalies, language="en"): async def mint_profile(self, signature, channel, anomalies, language="en", entropy=None):
profile = fallback_profile(signature) profile = fallback_profile(signature)
profile["rarity"] = "mythic" profile["rarity"] = "mythic"
return profile return profile
@@ -384,7 +443,7 @@ async def test_summon_common_rarity_does_not_roll_a_drop(sync_client, db_session
return profile return profile
class CommonSpiritService: class CommonSpiritService:
async def mint_profile(self, signature, channel, anomalies, language="en"): async def mint_profile(self, signature, channel, anomalies, language="en", entropy=None):
return _common_only(signature) return _common_only(signature)
async def fragment(self, source, anomaly, language="en"): async def fragment(self, source, anomaly, language="en"):

View File

@@ -87,7 +87,17 @@ export type ClientFrame =
| { type: 'ping' } | { type: 'ping' }
| { type: 'set_mode'; mode: Mode } | { type: 'set_mode'; mode: Mode }
| { type: 'language'; language: Language } | { type: 'language'; language: Language }
| { type: 'summon' } | {
type: 'summon'
/** Conditioned physical noise harvested from the seeker's room (see
* lib/entropy.ts). Optional: contact works without it, just without
* the room's participation. The server treats this as untrusted and
* only ever mixes it with its own secrets. */
entropy?: string
/** Longitude only, if location was granted — all that solar midnight
* needs. Deliberately not a full coordinate. */
longitude?: number
}
| { type: 'anomaly'; source: 'radio' | 'evp' | 'wire' | 'emf'; frequency: number; magnitude: number } | { type: 'anomaly'; source: 'radio' | 'evp' | 'wire' | 'emf'; frequency: number; magnitude: number }
| { type: 'question'; text: string } | { type: 'question'; text: string }
| { type: 'passive'; enabled: boolean } | { type: 'passive'; enabled: boolean }

View File

@@ -480,7 +480,7 @@ function WirePanel() {
// ---- EVP panel: microphone voice-band listening ---- // ---- EVP panel: microphone voice-band listening ----
function EvpPanel() { function EvpPanel() {
const { sendAnomaly } = useSeance() const { sendAnomaly, entropy } = useSeance()
const { user } = useAuth() const { user } = useAuth()
const { t } = useTranslation() const { t } = useTranslation()
// "listening_tool" unlock (GET /auth/me's `unlocks`) lowers the EVP // "listening_tool" unlock (GET /auth/me's `unlocks`) lowers the EVP
@@ -546,6 +546,9 @@ function EvpPanel() {
const out = buffers[0] === binsRef.current ? buffers[1] : buffers[0] const out = buffers[0] === binsRef.current ? buffers[1] : buffers[0]
for (let i = 0; i < db.length; i++) out[i] = db[i] for (let i = 0; i < db.length; i++) out[i] = db[i]
binsRef.current = out binsRef.current = out
// Real thermal/acoustic noise from this room, banked toward the
// next summon — see lib/entropy.ts.
entropy.addFrame(db)
const now = performance.now() const now = performance.now()
if (now - lastMeterRef.current > 200) { if (now - lastMeterRef.current > 200) {
@@ -640,7 +643,7 @@ function EvpPanel() {
// ---- radio panel: RTL-SDR FM sweep ---- // ---- radio panel: RTL-SDR FM sweep ----
function RadioPanel() { function RadioPanel() {
const { sendAnomaly } = useSeance() const { sendAnomaly, entropy } = useSeance()
const { t } = useTranslation() const { t } = useTranslation()
// WebUSB hides itself entirely outside secure ground (https / localhost). // WebUSB hides itself entirely outside secure ground (https / localhost).
const secure = useMemo(() => isSecureContext(), []) const secure = useMemo(() => isSecureContext(), [])
@@ -721,6 +724,7 @@ function RadioPanel() {
// this is already a distinct object each time — the scope uses // this is already a distinct object each time — the scope uses
// that identity change to know a new row is ready. // that identity change to know a new row is ready.
binsRef.current = db binsRef.current = db
entropy.addFrame(db)
const now = performance.now() const now = performance.now()
if (now - lastTuneUiRef.current > 250) { if (now - lastTuneUiRef.current > 250) {
lastTuneUiRef.current = now lastTuneUiRef.current = now

View File

@@ -16,6 +16,7 @@ import { VeilSocket } from '../lib/ws'
import type { VeilConnectionState } from '../lib/ws' import type { VeilConnectionState } from '../lib/ws'
import { SpiritAudioPlayer } from '../lib/audio' import { SpiritAudioPlayer } from '../lib/audio'
import { ghostLogBus } from '../lib/ghostLogBus' import { ghostLogBus } from '../lib/ghostLogBus'
import { EntropyPool } from '../lib/entropy'
import type { import type {
EntityTraits, EntityTraits,
JudgmentConsequence, JudgmentConsequence,
@@ -511,6 +512,10 @@ export type SeanceApi = {
setPassive: (enabled: boolean) => void setPassive: (enabled: boolean) => void
setLanguage: (language: Language) => void setLanguage: (language: Language) => void
summon: () => void summon: () => void
/** Shared pool every live sensor feeds real physical noise into. Drained
* and sent with each summon, so the room genuinely participates in what
* answers — see backend app/entropy.py. */
entropy: EntropyPool
ask: (text: string) => void ask: (text: string) => void
sendAnomaly: (source: 'radio' | 'evp' | 'wire' | 'emf', frequency: number, magnitude: number) => void sendAnomaly: (source: 'radio' | 'evp' | 'wire' | 'emf', frequency: number, magnitude: number) => void
playUtterance: (utteranceId: string) => void playUtterance: (utteranceId: string) => void
@@ -528,6 +533,11 @@ export const SeanceContext = createContext<SeanceApi | null>(null)
export function SeanceProvider({ children }: { children: ReactNode }) { export function SeanceProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(seanceReducer, initialSeanceState) const [state, dispatch] = useReducer(seanceReducer, initialSeanceState)
const socketRef = useRef<VeilSocket | null>(null) const socketRef = useRef<VeilSocket | null>(null)
const entropyRef = useRef(new EntropyPool())
// Longitude only — all solar midnight needs. Requested once, silently:
// a denied or unavailable location simply means the celestial reading
// falls back to moon-only, never an error the seeker has to dismiss.
const longitudeRef = useRef<number | null>(null)
const playerRef = useRef<SpiritAudioPlayer | null>(null) const playerRef = useRef<SpiritAudioPlayer | null>(null)
const stateRef = useRef(state) const stateRef = useRef(state)
stateRef.current = state stateRef.current = state
@@ -580,6 +590,30 @@ export function SeanceProvider({ children }: { children: ReactNode }) {
} }
}, []) }, [])
// Ask for location once, quietly, and only keep the longitude — it is
// all solar midnight needs (see backend app/celestial.py), and keeping a
// full coordinate would mean retaining precise location we have no use
// for. A denial or a device without GPS is not an error path: the
// celestial reading simply falls back to moon-only, and nothing is ever
// shown to the seeker about it.
useEffect(() => {
if (typeof navigator === 'undefined' || !navigator.geolocation) return
let cancelled = false
navigator.geolocation.getCurrentPosition(
(pos) => {
if (!cancelled) longitudeRef.current = pos.coords.longitude
},
() => undefined,
// Low accuracy on purpose: a coarse fix is plenty for a
// hour-of-day calculation, and it is far faster and cheaper on
// battery than waking the GPS for a precise one.
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 600_000 },
)
return () => {
cancelled = true
}
}, [])
const setMode = useCallback((mode: Mode) => { const setMode = useCallback((mode: Mode) => {
dispatch({ type: 'set_mode', mode }) dispatch({ type: 'set_mode', mode })
socketRef.current?.send({ type: 'set_mode', mode }) socketRef.current?.send({ type: 'set_mode', mode })
@@ -596,7 +630,21 @@ export function SeanceProvider({ children }: { children: ReactNode }) {
}, []) }, [])
const summon = useCallback(() => { const summon = useCallback(() => {
socketRef.current?.send({ type: 'summon' }) // Drain whatever physical noise the running sensors have banked and
// send it with the request. Async because conditioning goes through
// WebCrypto; the summon is sent either way, since entropy is an
// enrichment and never a precondition for contact.
void entropyRef.current
.drain()
.catch(() => null)
.then((contribution) => {
const lon = longitudeRef.current
socketRef.current?.send({
type: 'summon',
...(contribution ? { entropy: contribution } : {}),
...(lon !== null ? { longitude: lon } : {}),
})
})
}, []) }, [])
const ask = useCallback((text: string) => { const ask = useCallback((text: string) => {
@@ -652,6 +700,7 @@ export function SeanceProvider({ children }: { children: ReactNode }) {
setPassive, setPassive,
setLanguage, setLanguage,
summon, summon,
entropy: entropyRef.current,
ask, ask,
sendAnomaly, sendAnomaly,
playUtterance, playUtterance,