Files
qtalker---/backend/app/entities.py
Indiana b8e69b4bd3 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>
2026-07-28 05:38:59 +00:00

213 lines
8.7 KiB
Python

"""Entity identity: anomaly-signature fingerprinting, Codex matching, and
procedural fallback profiles so a summoning always succeeds even if the
LLM box is dark."""
import hashlib
import json
import random
from app.entropy import veil_random
from app.models.entity import RARITY_TIERS
from app.tts.voices import EN_VOICE_IDS
# An anomaly stream must show this much structure before it can fingerprint
# a spirit.
MIN_ANOMALIES_FOR_SIGNATURE = 3
_NAME_PARTS = [
("Ash", "Briar", "Cinder", "Dusk", "Elm", "Fen", "Grim", "Hollow", "Iris",
"Lark", "Marrow", "Nix", "Opal", "Pyre", "Quill", "Rue", "Sable", "Thorn",
"Umber", "Vesper", "Wren", "Yew"),
("belle", "brook", "feld", "gate", "hart", "latch", "mere", "moor",
"shade", "song", "thorne", "vale", "ward", "wick", "wither", "wood"),
]
_EPITHETS = [
"the Static Widow", "the Hollow Bell", "the Cartographer of Lost Rooms",
"the Choir of One", "the Lantern Bearer", "the Unburied", "the Frequency",
"the Long Silence", "the Cartonist", "the Slow Knife", "the Drowned Signal",
"the Cartographer", "she who Counts", "the Tenant", "the Understudy",
"the Mnemosyne Worm", "the Pale Frequency", "the Last Broadcast",
]
_PERSONA_TEMPLATES = [
"{name} died with a sentence unfinished and has been trying to end it ever since. "
"They press words into any carrier wave that passes, patient as erosion.",
"{name} was a voice once — a singer, a caller of trains, a reader of weather. "
"Now they are only the voice, worn smooth as sea glass, speaking through static.",
"{name} does not remember dying. They remember a room, a light going violet at the "
"edges, and then the long hum. They are still in the room. The room is everywhere.",
"{name} clings to the wires the way smoke clings to a ceiling. They answer questions "
"the way a mirror answers light: exactly, and never the way you hoped.",
]
_QUOTE_BANK = [
"I am closer than the dial suggests.",
"The static is not empty. It is crowded.",
"You hear me because you are quiet enough.",
"I remember the rain. It is still raining here.",
"Do not ask what I want. Ask what I remember.",
"The wire hums with all of us.",
"Speak slower. I am gathering.",
"Your light is warm. I mean no harm. Mostly.",
]
def signature_from_anomalies(anomalies: list[dict]) -> str | None:
"""Fingerprint a session's anomaly pattern. Returns None when the stream
is too thin to carry an identity."""
if len(anomalies) < MIN_ANOMALIES_FOR_SIGNATURE:
return None
freq_buckets: dict[int, int] = {}
for anomaly in anomalies:
try:
freq = float(anomaly.get("frequency", 0))
except (TypeError, ValueError):
freq = 0.0
# MHz radio freqs and Hz audio freqs land in the same log-scale band
# space; magnitude ordering is what matters, not the unit.
bucket = int(len(str(int(abs(freq))))) if freq else 0
freq_buckets[bucket] = freq_buckets.get(bucket, 0) + 1
magnitudes = sorted(
float(a.get("magnitude", 0) or 0) for a in anomalies[-16:]
)
pattern = f"{sorted(freq_buckets.items())}|{[round(m, 1) for m in magnitudes]}"
return hashlib.sha1(pattern.encode()).hexdigest()[:16]
def fallback_signature(seed: str) -> str:
"""Deterministic signature for anomaly-thin sessions (e.g. pure chat)."""
return hashlib.sha1(f"ambient:{seed}".encode()).hexdigest()[:16]
def parse_mint_response(text: str) -> dict | None:
"""Pull the first JSON object out of an LLM mint reply."""
start = text.find("{")
end = text.rfind("}")
if start == -1 or end <= start:
return None
try:
profile = json.loads(text[start : end + 1])
except json.JSONDecodeError:
return None
if not isinstance(profile.get("name"), str) or not profile["name"].strip():
return None
return profile
def roll_traits(signature: str, entropy: object = None) -> dict:
"""Roll the four hidden truth-traits for an entity. These are never
derived from — or fed into — the LLM persona prompt (see `mint_prompt`,
which never sees this function's output): persona text must stay fully
decoupled from ground truth, so a convincing "sweet old lady" persona
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
`"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}")
return {
"alignment": rng.uniform(0.0, 1.0),
"power": rng.uniform(0.0, 1.0),
"volatility": rng.uniform(0.0, 1.0),
"deceptiveness": rng.uniform(0.0, 1.0),
}
def normalize_profile(profile: dict, signature: str, entropy: object = None) -> dict:
"""Coerce an LLM (or fallback) profile into the exact shape the DB and
frontend expect, filling gaps with defaults.
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 {}
visual = profile.get("visual") if isinstance(profile.get("visual"), dict) else {}
quotes = profile.get("quotes") if isinstance(profile.get("quotes"), list) else []
rarity = str(profile.get("rarity", "common")).lower()
if rarity not in RARITY_TIERS:
rarity = "common"
form = str(visual.get("form", "wisp")).lower()
if form not in ("wisp", "banshee", "fairy", "shade"):
form = "wisp"
def _clamp(value, low, high, default):
try:
return max(low, min(high, float(value)))
except (TypeError, ValueError):
return default
return {
"name": str(profile.get("name", "The Unnamed"))[:64].strip() or "The Unnamed",
"epithet": str(profile.get("epithet", rng.choice(_EPITHETS)))[:128],
"persona": str(profile.get("persona", ""))[:1200],
"rarity": rarity,
"voice": {
"voice_id": voice.get("voice_id")
if voice.get("voice_id") in EN_VOICE_IDS + ["davefx", "ald"]
else rng.choice(EN_VOICE_IDS),
"pitch": _clamp(voice.get("pitch"), -6, 6, rng.uniform(-3, 3)),
"rate": _clamp(voice.get("rate"), 0.8, 1.15, rng.uniform(0.9, 1.05)),
"noise": _clamp(voice.get("noise"), 0.01, 0.08, rng.uniform(0.02, 0.05)),
"echo": _clamp(voice.get("echo"), 0.0, 0.5, rng.uniform(0.1, 0.3)),
},
"visual": {
"hue": _clamp(visual.get("hue"), 0, 360, rng.uniform(0, 360)),
"form": form,
},
"quotes": [str(q)[:200] for q in quotes[:4] if isinstance(q, str)]
or rng.sample(_QUOTE_BANK, 2),
"traits": roll_traits(signature, entropy),
}
def fallback_profile(signature: str, entropy: object = None) -> dict:
"""Procedural persona for when the LLM box is unreachable — a summoning
must never visibly fail.
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])
epithet = rng.choice(_EPITHETS)
persona = rng.choice(_PERSONA_TEMPLATES).format(name=name)
rarity = rng.choices(RARITY_TIERS, weights=[55, 30, 12, 3])[0]
return normalize_profile(
{
"name": name,
"epithet": epithet,
"persona": persona,
"rarity": rarity,
"quotes": rng.sample(_QUOTE_BANK, 2),
},
signature,
entropy,
)