diff --git a/backend/app/main.py b/backend/app/main.py index 91ad0ef..fc9dc8c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,7 +9,6 @@ from fastapi.staticfiles import StaticFiles from sqlalchemy import text import app.models # noqa: F401 — registers models on Base.metadata before create_all -from app.config import settings from app.db import Base, async_session_maker, engine from app.routes.auth import router as auth_router from app.routes.codex import router as codex_router diff --git a/backend/tests/test_ws_session.py b/backend/tests/test_ws_session.py index f1f6e50..623b406 100644 --- a/backend/tests/test_ws_session.py +++ b/backend/tests/test_ws_session.py @@ -169,6 +169,14 @@ async def test_familiar_presence_answers_again_on_a_known_channel(sync_client, m (`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. + + It ALSO pins VEIL_THINNESS_PULL to 0. The real return chance is + `RETURN_CHANCE * (1 - veil_thinness * PULL)`, and veil_thinness is + driven by the *actual current moon phase and geomagnetic Kp*. On a + full-moon test run, thinness ≈ 1 drags even a pinned RETURN_CHANCE=1.0 + down to ~0.55 — so without this, the test is silently date-dependent + and fails ~45% of the time in the wrong week. The veil-thinness + influence has its own dedicated tests; this one isolates re-contact. """ # Scoped limiters: the module-level ones are shared singletons that # accumulate across the whole session, and this test summons more than @@ -180,6 +188,7 @@ async def test_familiar_presence_answers_again_on_a_known_channel(sync_client, m app.ws, "summon_ip_limiter", RateLimiter(max_requests=100, window_seconds=60) ) monkeypatch.setattr(app.ws, "RETURN_CHANCE", 1.0) + monkeypatch.setattr(app.ws, "VEIL_THINNESS_PULL", 0.0) _login(sync_client, "mediumx") anomalies = [ {"type": "anomaly", "source": "radio", "frequency": 101.0 + i, "magnitude": 5.0 + i} diff --git a/frontend/src/components/RitualPanel.tsx b/frontend/src/components/RitualPanel.tsx index 63dd22a..9da3c8b 100644 --- a/frontend/src/components/RitualPanel.tsx +++ b/frontend/src/components/RitualPanel.tsx @@ -18,7 +18,6 @@ import type { CSSProperties } from 'react' import { useTranslation } from 'react-i18next' import { useSeance } from '../state/seance' import { - RITUAL_HOLD_MS, RITUAL_PROMPTS, holdProgress, nextStep, diff --git a/frontend/src/components/SigilDesigner.test.tsx b/frontend/src/components/SigilDesigner.test.tsx index 2051871..9d8b566 100644 --- a/frontend/src/components/SigilDesigner.test.tsx +++ b/frontend/src/components/SigilDesigner.test.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import '../i18n' import { SigilDesigner } from './SigilDesigner' import { RUNES, SIGIL_MAX_POINTS, SIGIL_SLOT_COUNT } from '../lib/sigil' diff --git a/frontend/src/lib/baseline.ts b/frontend/src/lib/baseline.ts index 49851fd..4ca75b1 100644 --- a/frontend/src/lib/baseline.ts +++ b/frontend/src/lib/baseline.ts @@ -36,6 +36,25 @@ export type BaselineEvent = { at: number } +/** + * EMA smoothing factor from elapsed wall-clock time, not sample count. + * + * Sensor intervals here are irregular — a BLE beacon may advertise every + * 100ms or every 2s, a device can drop offline and reconnect — so a fixed + * per-sample alpha would weight a burst and a long gap identically. The + * exponential-decay form degrades gracefully at both ends: a long gap + * pushes alpha toward 1 (the old baseline is stale, trust the new reading), + * a rapid burst toward 0 (barely move it). + * + * A non-positive dt (duplicate timestamps, clock skew, two readings in one + * tick) can't be trusted as an elapsed time, so it falls back to a flat + * step rather than dividing by it. Shared by ThresholdBaseline and + * coldSpot's pure-function core so the one formula lives in one place. + */ +export function emaAlpha(dtMs: number, tauMs: number): number { + return dtMs > 0 ? 1 - Math.exp(-dtMs / tauMs) : 0.15 +} + export type ThresholdBaselineOptions = { /** Deviation magnitude that counts as an event, in the sensor's own unit. */ threshold: number @@ -91,11 +110,8 @@ export class ThresholdBaseline { const deviation = value - this.mean const warm = this.warm - // Guard against zero/negative dt (duplicate timestamps, clock skew, two - // readings in one tick) with a flat fallback rather than dividing by an - // elapsed time that isn't trustworthy. const dt = this.lastAt === null ? 0 : atMs - this.lastAt - const alpha = dt > 0 ? 1 - Math.exp(-dt / this.opts.tauMs) : 0.15 + const alpha = emaAlpha(dt, this.opts.tauMs) this.mean = this.mean + alpha * deviation this.lastAt = atMs this.samples++ diff --git a/frontend/src/lib/coldSpot.ts b/frontend/src/lib/coldSpot.ts index ccefd7f..4471e3e 100644 --- a/frontend/src/lib/coldSpot.ts +++ b/frontend/src/lib/coldSpot.ts @@ -22,6 +22,8 @@ // why that's an EMA keyed on elapsed wall-clock time rather than sample // count — real hardware does not report on a fixed schedule. +import { emaAlpha } from './baseline' + // --------------------------------------------------------------------------- // Shared baseline core // --------------------------------------------------------------------------- @@ -113,12 +115,8 @@ export function applyReading( const deviation = value - state.mean const warm = state.sampleCount >= config.minSamples - // Guard against zero/negative/out-of-order dt (duplicate timestamps, - // clock skew, or two readings racing in the same tick) with a flat - // fallback step rather than dividing by an elapsed time that isn't - // trustworthy. const dtMs = state.lastAt === null ? 0 : atMs - state.lastAt - const alpha = dtMs > 0 ? 1 - Math.exp(-dtMs / config.tauMs) : 0.15 + const alpha = emaAlpha(dtMs, config.tauMs) const nextMean = state.mean + alpha * deviation return {