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:
176
backend/tests/test_celestial.py
Normal file
176
backend/tests/test_celestial.py
Normal 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
|
||||
117
backend/tests/test_entropy.py
Normal file
117
backend/tests/test_entropy.py
Normal 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
|
||||
@@ -21,7 +21,7 @@ from app.rate_limit import RateLimiter
|
||||
|
||||
|
||||
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)
|
||||
|
||||
async def fragment(self, source, anomaly, language="en"):
|
||||
|
||||
@@ -15,7 +15,7 @@ from app.rate_limit import RateLimiter
|
||||
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
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")
|
||||
anomalies = [
|
||||
{"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
|
||||
|
||||
|
||||
@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
|
||||
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
|
||||
@@ -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))
|
||||
|
||||
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["rarity"] = "mythic"
|
||||
return profile
|
||||
@@ -384,7 +443,7 @@ async def test_summon_common_rarity_does_not_roll_a_drop(sync_client, db_session
|
||||
return profile
|
||||
|
||||
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)
|
||||
|
||||
async def fragment(self, source, anomaly, language="en"):
|
||||
|
||||
Reference in New Issue
Block a user