mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-24 23:41:01 +02:00
feat(observatory): live event bridge + live FSRS decay field (Phase 0+1)
The raw-WebGPU field was inert — it ran the deterministic DemoClock and never heard the backend. This wires it to the real event stream and makes every memory visibly forget on its true FSRS curve. - LiveBridge (new): lock-free per-frame drain via engine.preFrameHook, ingests $eventFeed oldest-first, decodes real events into live lanes. - fsrs.ts (new): exact TS port of retrievability_with_decay (w20=0.1542). - params 12→16 floats (capture_mode + 4 live lanes); all 8 WGSL Params structs updated in lockstep. _pad renamed to its real meaning. - node-renderer: live-retention GPU buffer (binding 4); simulate.wgsl overwrites vel_retention.w so render-nodes dims on the real curve. - ObservatoryStage: `live` prop, $eventFeed subscription, persistent FORGETTING HORIZON scrubber (project the curve forward N days, honest). - backend: get_graph node json now carries stability + lastAccessed. Verified live vs the real 1241-memory brain: +300d scrub visibly decays low-stability nodes while high-stability survive; per-node FSRS math confirmed. svelte-check 0 err, 1033 tests, build, cargo check all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
9f5a1fde4a
commit
addbf260f1
17 changed files with 770 additions and 21 deletions
|
|
@ -14,6 +14,7 @@
|
|||
*/
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$stores/api';
|
||||
import { eventFeed } from '$stores/websocket';
|
||||
import type { GraphResponse } from '$types';
|
||||
import TelemetryStrip from '$lib/observatory/overlays/TelemetryStrip.svelte';
|
||||
import TimelineSpine from '$lib/observatory/overlays/TimelineSpine.svelte';
|
||||
|
|
@ -30,6 +31,7 @@
|
|||
import { FirewallRenderer } from '$lib/observatory/firewall-renderer';
|
||||
import { buildFirewallPlan, type FirewallPlan } from '$lib/observatory/firewall-plan';
|
||||
import type { PathStepMeta } from '$lib/observatory/path-builder';
|
||||
import { LiveBridge } from '$lib/observatory/live-bridge';
|
||||
|
||||
interface Props {
|
||||
demo: DemoMode;
|
||||
|
|
@ -62,6 +64,15 @@
|
|||
* back with its memory id (the host opens its inspector panel).
|
||||
*/
|
||||
onpick?: (memoryId: string) => void;
|
||||
/**
|
||||
* v2.3 living field — subscribe the field to the REAL backend event
|
||||
* stream ($eventFeed) so it renders live FSRS decay, the contradiction
|
||||
* firewall, the dream storm, and the causal recall wavefront driven by
|
||||
* real cognitive events instead of the deterministic DemoClock. Only the
|
||||
* main graph's field renderer turns this on; the scripted ?demo= moments
|
||||
* stay deterministic (live=false).
|
||||
*/
|
||||
live?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -74,9 +85,18 @@
|
|||
onexit,
|
||||
embedded = false,
|
||||
chrome = 'full',
|
||||
onpick
|
||||
onpick,
|
||||
live = false
|
||||
}: Props = $props();
|
||||
|
||||
// v2.3 living field — forward-projection scrubber (Phase 1 honesty control).
|
||||
// Real FSRS drift over a viewing session is imperceptibly slow, so this adds
|
||||
// N days to every node's real elapsed and recomputes decay on the SAME true
|
||||
// curve — legible, not faked. 0 = "now". Bound by the on-page control below.
|
||||
let projectionDays = $state(0);
|
||||
let liveBridge: LiveBridge | null = null;
|
||||
let liveDecayReady = $state(false);
|
||||
|
||||
// GPU picking — screen px → NDC → NodeRenderer.pickAt (one readback/click).
|
||||
let canvasLayerEl: HTMLDivElement | null = $state(null);
|
||||
async function handleFieldClick(e: MouseEvent) {
|
||||
|
|
@ -266,12 +286,44 @@
|
|||
pathSteps = renderer.pathSteps;
|
||||
}
|
||||
|
||||
// v2.3 living field — wire the field to the REAL backend event stream.
|
||||
// Only the main graph's field renderer (live=true); the scripted
|
||||
// ?demo= moments stay deterministic. The bridge drives the live lanes
|
||||
// + per-node FSRS decay via the engine's preFrameHook; the eventFeed
|
||||
// subscription below feeds it. Created here (once) because it needs
|
||||
// both the engine AND renderer.graph, which only exist post-upload.
|
||||
if (live && renderer.graph) {
|
||||
liveBridge = new LiveBridge({
|
||||
engine,
|
||||
renderer,
|
||||
graph: renderer.graph,
|
||||
projectionDays: () => projectionDays
|
||||
});
|
||||
liveDecayReady = liveBridge.liveDecayAvailable;
|
||||
engine.setPreFrameHook((simFrame) => liveBridge?.drain(simFrame));
|
||||
}
|
||||
|
||||
// Start the story at frame 0 now that the field exists — where the
|
||||
// loop begins must never depend on how long the API call took.
|
||||
engine.demoClock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
// Feed the live bridge every time the event store changes (newest-first).
|
||||
// The bridge dedupes internally (only events newer than the last seen are
|
||||
// applied) and is a no-op when live=false / the bridge isn't built yet.
|
||||
$effect(() => {
|
||||
const events = $eventFeed;
|
||||
if (liveBridge) liveBridge.ingest(events);
|
||||
});
|
||||
|
||||
// The forward-projection scrubber recomputes decay immediately on change so
|
||||
// dragging the slider is responsive (the per-frame drain throttles decay).
|
||||
$effect(() => {
|
||||
void projectionDays;
|
||||
liveBridge?.refreshDecay();
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
loadGraph();
|
||||
});
|
||||
|
|
@ -307,6 +359,42 @@
|
|||
chrome='none' leaves only loading/error (host page owns the chrome). -->
|
||||
{#if showHud}
|
||||
<div class="absolute inset-0 z-10 pointer-events-none">
|
||||
<!-- v2.3 living field — forward-projection scrubber. Real per-memory FSRS
|
||||
decay drifts too slowly to watch in one session, so this projects the
|
||||
field N days forward on the SAME true forgetting curve (honest, not
|
||||
faked). Only shown when the backend serves real FSRS state. -->
|
||||
{#if live && liveDecayReady}
|
||||
<div
|
||||
class="absolute bottom-4 left-1/2 -translate-x-1/2 pointer-events-auto
|
||||
flex items-center gap-3 px-4 py-2.5 rounded-xl border border-[#22C7DE]/25
|
||||
bg-[#05060a]/80 backdrop-blur-sm font-mono text-[11px] tracking-wide"
|
||||
>
|
||||
<span class="text-[#22C7DE]/80 uppercase">Forgetting horizon</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="365"
|
||||
step="1"
|
||||
bind:value={projectionDays}
|
||||
class="w-40 sm:w-56 accent-[#22C7DE] cursor-pointer"
|
||||
aria-label="Project the forgetting curve forward N days"
|
||||
title="Project every memory forward on its real FSRS curve"
|
||||
/>
|
||||
<span class="text-[#7fe6c0] w-16 text-right tabular-nums">
|
||||
{projectionDays === 0 ? 'now' : `+${projectionDays}d`}
|
||||
</span>
|
||||
{#if projectionDays !== 0}
|
||||
<button
|
||||
onclick={() => (projectionDays = 0)}
|
||||
class="text-[#22C7DE]/60 hover:text-[#22C7DE] transition-colors"
|
||||
title="Return to now"
|
||||
>
|
||||
reset
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if chrome === 'full'}
|
||||
<!-- Top telemetry strip -->
|
||||
<TelemetryStrip
|
||||
|
|
|
|||
|
|
@ -88,6 +88,17 @@ export class ObservatoryEngine {
|
|||
private _status: EngineStatus = { state: 'booting' };
|
||||
private statusListeners = new Set<(s: EngineStatus) => void>();
|
||||
|
||||
/**
|
||||
* Live-event hook (v2.3 living field). Invoked once per frame AFTER p[0..11]
|
||||
* are set but BEFORE the params buffer is written to the GPU, so the live
|
||||
* bridge can drive lanes 12..15 (liveKind/liveStartFrame/liveEnergy/
|
||||
* projectionDays) and mutate node buffers from the real backend WebSocket
|
||||
* stream. `simFrame` is the monotonic (non-wrapping) sim frame so live
|
||||
* event envelopes never pop at the 720-frame loop seam. Allocation-free by
|
||||
* contract — the bridge drains a preallocated ring buffer here.
|
||||
*/
|
||||
private preFrameHook: ((simFrame: number) => void) | null = null;
|
||||
|
||||
// fps estimate for telemetry only (never sim state)
|
||||
private lastRafTs = 0;
|
||||
private fpsEstimate = 0;
|
||||
|
|
@ -154,6 +165,29 @@ export class ObservatoryEngine {
|
|||
this.passes.push(pass);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the per-frame live-event hook (v2.3). See `preFrameHook`. Pass
|
||||
* null to detach (the field falls back to the calm deterministic loop).
|
||||
*/
|
||||
setPreFrameHook(hook: ((simFrame: number) => void) | null): void {
|
||||
this.preFrameHook = hook;
|
||||
}
|
||||
|
||||
/** Monotonic sim frame (does NOT wrap at the loop period). */
|
||||
get totalFrames(): number {
|
||||
return this.clock.state.totalFrames;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wall-clock now in ms. The ONE sanctioned wall-clock read (never for sim
|
||||
* state — the DemoClock owns that). The live FSRS decay field legitimately
|
||||
* needs real time to compute "days since last review"; that is a real
|
||||
* external fact, not simulation state, so it does not break determinism.
|
||||
*/
|
||||
get wallNowMs(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
/** Boot WebGPU. Resolves true when running, false when unsupported/error. */
|
||||
async start(): Promise<boolean> {
|
||||
if (this.disposed) return false;
|
||||
|
|
@ -319,6 +353,14 @@ export class ObservatoryEngine {
|
|||
// storage-buffer state stays frozen at the initial upload values,
|
||||
// making same URL + frame → identical pixels (spec §4 Inc 9).
|
||||
p[11] = this.freezeFrame !== null ? 1.0 : 0.0;
|
||||
|
||||
// v2.3 living field — let the live bridge drive lanes 12..15 and mutate
|
||||
// node buffers from the real backend event stream. Runs on the
|
||||
// monotonic sim frame so envelopes never pop at the loop seam. Lanes
|
||||
// 12..15 persist across frames in `this.params` (the loop above only
|
||||
// writes 0..11), so a settled field with no live events stays calm.
|
||||
this.preFrameHook?.(state.totalFrames);
|
||||
|
||||
this.device.queue.writeBuffer(this.paramsBuffer, 0, p);
|
||||
|
||||
let swapTex: GPUTexture;
|
||||
|
|
|
|||
84
apps/dashboard/src/lib/observatory/fsrs.ts
Normal file
84
apps/dashboard/src/lib/observatory/fsrs.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* FSRS-6 retrievability — an EXACT TypeScript port of the Rust closed form
|
||||
* (crates/vestige-core/src/fsrs/algorithm.rs:101 `retrievability_with_decay`).
|
||||
*
|
||||
* This is the moat, not decoration: the living decay field dims each memory on
|
||||
* its REAL per-memory forgetting curve. Every value here is verified against
|
||||
* source — nothing is approximated, so the field is honest by construction
|
||||
* (feed it noise instead of the real curve and the drift is legibly wrong).
|
||||
*
|
||||
* factor = 0.9^(-1/w20) - 1
|
||||
* R = (1 + factor * elapsed_days / stability)^(-w20)
|
||||
*
|
||||
* with the personalizable w20 defaulting to the engine's DEFAULT_DECAY.
|
||||
* stability is in DAYS; elapsed_days is days since last review.
|
||||
*/
|
||||
|
||||
/**
|
||||
* FSRS-6 default forgetting-curve decay (w20). Mirrors the Rust
|
||||
* `DEFAULT_DECAY = 0.1542` constant. NOTE: not 0.01036 — that value appears
|
||||
* nowhere in the engine; verified against algorithm.rs on 2026-07-08.
|
||||
*/
|
||||
export const DEFAULT_DECAY = 0.1542;
|
||||
|
||||
/** FSRS-6 forgetting factor: `0.9^(-1/w20) - 1`. */
|
||||
export function forgettingFactor(w20: number = DEFAULT_DECAY): number {
|
||||
return Math.pow(0.9, -1 / w20) - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probability of recall (0..1) for a memory of the given `stability` (days)
|
||||
* after `elapsedDays` since its last review. Exact port of the Rust guard
|
||||
* clauses: stability<=0 → 0, elapsed<=0 → 1.
|
||||
*/
|
||||
export function retrievability(
|
||||
stability: number,
|
||||
elapsedDays: number,
|
||||
w20: number = DEFAULT_DECAY
|
||||
): number {
|
||||
if (!(stability > 0)) return 0;
|
||||
if (!(elapsedDays > 0)) return 1;
|
||||
const factor = forgettingFactor(w20);
|
||||
const r = Math.pow(1 + (factor * elapsedDays) / stability, -w20);
|
||||
return r < 0 ? 0 : r > 1 ? 1 : r;
|
||||
}
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
/**
|
||||
* Days elapsed between an ISO timestamp (`lastAccessed`) and `nowMs`, plus an
|
||||
* optional forward projection. The projection is the Phase-1 honesty control:
|
||||
* with live w20 and multi-day stabilities, real drift over a viewing session
|
||||
* is imperceptibly slow, so the scrubber recomputes elapsed at `t + N days` on
|
||||
* the SAME true curve — fully honest, just legible.
|
||||
*
|
||||
* Returns 0 for a missing/unparseable timestamp so a node with no last-access
|
||||
* simply reads as freshly reviewed (R=1) rather than crashing the field.
|
||||
*/
|
||||
export function elapsedDays(
|
||||
lastAccessedIso: string | undefined,
|
||||
nowMs: number,
|
||||
projectionDays = 0
|
||||
): number {
|
||||
if (!lastAccessedIso) return projectionDays > 0 ? projectionDays : 0;
|
||||
const t = Date.parse(lastAccessedIso);
|
||||
if (!Number.isFinite(t)) return projectionDays > 0 ? projectionDays : 0;
|
||||
const days = (nowMs - t) / MS_PER_DAY;
|
||||
return Math.max(0, days) + Math.max(0, projectionDays);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live retrievability for a memory from its real FSRS state, at `nowMs` with an
|
||||
* optional forward projection. This is the single value the decay field writes
|
||||
* into each node's `vel_retention.w` every frame.
|
||||
*/
|
||||
export function liveRetrievability(
|
||||
stability: number | undefined,
|
||||
lastAccessedIso: string | undefined,
|
||||
nowMs: number,
|
||||
projectionDays = 0,
|
||||
w20: number = DEFAULT_DECAY
|
||||
): number {
|
||||
if (stability === undefined || !Number.isFinite(stability)) return 1;
|
||||
return retrievability(stability, elapsedDays(lastAccessedIso, nowMs, projectionDays), w20);
|
||||
}
|
||||
368
apps/dashboard/src/lib/observatory/live-bridge.ts
Normal file
368
apps/dashboard/src/lib/observatory/live-bridge.ts
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
/**
|
||||
* Live event bridge — the field's nervous system (Phase 0, v2.3).
|
||||
*
|
||||
* The Observatory field was inert: it ran the deterministic DemoClock and never
|
||||
* heard the backend. This bridge is the ONE integration that makes it live —
|
||||
* every hero (live FSRS decay, the contradiction firewall, the dream storm, the
|
||||
* causal recall wavefront) rides it.
|
||||
*
|
||||
* Contract:
|
||||
* - ObservatoryStage pushes new VestigeEvents (from $eventFeed) via `ingest`.
|
||||
* - The engine calls `drain(simFrame)` once per frame (its preFrameHook),
|
||||
* AFTER p[0..11] are set, BEFORE the params buffer is written to the GPU.
|
||||
* - `drain` applies queued events to engine params (lanes 12..15) and node
|
||||
* buffers for the current frame. Allocation-free in the hot path: a fixed
|
||||
* ring buffer of decoded events + preallocated typed arrays, no per-frame
|
||||
* GC pressure.
|
||||
*
|
||||
* Determinism note: the deterministic demo loop is untouched. This bridge only
|
||||
* writes the LIVE lanes (0.0 at rest) and the live-retention buffer, so a field
|
||||
* with no backend events is pixel-identical to the pre-bridge build.
|
||||
*/
|
||||
|
||||
import type { ObservatoryEngine } from './engine';
|
||||
import type { NodeRenderer } from './node-renderer';
|
||||
import type { VestigeEvent } from '$types';
|
||||
import { LIVE_KIND, PARAM_IDX, type ObservatoryGraph } from './types';
|
||||
import { liveRetrievability } from './fsrs';
|
||||
|
||||
/** A decoded live event, normalized off the wire's { type, data } envelope. */
|
||||
interface DecodedEvent {
|
||||
kind: number; // LIVE_KIND.*
|
||||
startFrame: number; // monotonic sim frame it fired
|
||||
/** Primary target node id (firewall/recall focus), '' if none. */
|
||||
targetId: string;
|
||||
/** Neighbor / path node ids the event references. */
|
||||
relatedIds: string[];
|
||||
/** Contradiction / connection pairs [a,b][] the event carries. */
|
||||
pairs: [string, string][];
|
||||
/** Free-form scalar (estimated_cascade, weight, memory_count …). */
|
||||
scalar: number;
|
||||
}
|
||||
|
||||
/** How long (sim frames @60fps) each live event's envelope plays before idle. */
|
||||
const LIVE_DURATION: Record<number, number> = {
|
||||
[LIVE_KIND.firewall]: 620, // full quarantine choreography (matches demo)
|
||||
[LIVE_KIND.dreamStorm]: 0, // open-ended: held until DreamCompleted arrives
|
||||
[LIVE_KIND.causalRecall]: 260, // wavefront sweep + afterglow
|
||||
[LIVE_KIND.birth]: 180
|
||||
};
|
||||
|
||||
export interface LiveBridgeDeps {
|
||||
engine: ObservatoryEngine;
|
||||
renderer: NodeRenderer;
|
||||
graph: ObservatoryGraph;
|
||||
/**
|
||||
* Forward-projection scrubber (Phase 1). Days added to every node's real
|
||||
* FSRS elapsed so decay is legible in one viewing session. A getter so the
|
||||
* bridge always reads the live control value without re-subscribing.
|
||||
*/
|
||||
projectionDays?: () => number;
|
||||
/** Dev/verification: mirror what the bridge applied, for assertions. */
|
||||
onApply?: (info: { simFrame: number; activeKind: number; eventsSeen: number }) => void;
|
||||
}
|
||||
|
||||
export class LiveBridge {
|
||||
private engine: ObservatoryEngine;
|
||||
private renderer: NodeRenderer;
|
||||
private graph: ObservatoryGraph;
|
||||
private projectionDays: () => number;
|
||||
private onApply?: LiveBridgeDeps['onApply'];
|
||||
|
||||
/** id → stable buffer index (from the ObservatoryGraph). */
|
||||
private indexById: Map<string, number>;
|
||||
|
||||
/** The single active live event (newest wins; heroes are momentary). */
|
||||
private active: DecodedEvent | null = null;
|
||||
/** True while a dream storm is open (between Started and Completed). */
|
||||
private dreamOpen = false;
|
||||
|
||||
/** Preallocated per-node live-retention mirror (Phase 1). */
|
||||
private retention: Float32Array;
|
||||
/** Whether any node carries real FSRS state (else keep static retention). */
|
||||
private hasLiveDecay = false;
|
||||
/** Monotonic counter of events applied — for the dev assertion. */
|
||||
private eventsSeen = 0;
|
||||
/** Last sim frame we recomputed decay (throttle: decay drifts slowly). */
|
||||
private lastDecayFrame = -1000;
|
||||
|
||||
constructor(deps: LiveBridgeDeps) {
|
||||
this.engine = deps.engine;
|
||||
this.renderer = deps.renderer;
|
||||
this.graph = deps.graph;
|
||||
this.projectionDays = deps.projectionDays ?? (() => 0);
|
||||
this.onApply = deps.onApply;
|
||||
this.indexById = deps.graph.indexById;
|
||||
|
||||
const n = deps.graph.nodes.length;
|
||||
this.retention = new Float32Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const node = deps.graph.nodes[i];
|
||||
this.retention[i] = node.retention;
|
||||
if (node.stability !== undefined && node.lastAccessed) this.hasLiveDecay = true;
|
||||
}
|
||||
|
||||
// Seed the live lanes to a calm resting state.
|
||||
const p = this.engine.params;
|
||||
p[PARAM_IDX.liveKind] = LIVE_KIND.none;
|
||||
p[PARAM_IDX.liveStartFrame] = 0;
|
||||
p[PARAM_IDX.liveEnergy] = 0;
|
||||
p[PARAM_IDX.projectionDays] = 0;
|
||||
}
|
||||
|
||||
/** Whether real per-memory FSRS decay data is present (Phase 1 gate). */
|
||||
get liveDecayAvailable(): boolean {
|
||||
return this.hasLiveDecay;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Ingestion — called from the ObservatoryStage $eventFeed subscription.
|
||||
// $eventFeed is newest-first; we apply oldest-first so cause precedes
|
||||
// effect (mirrors Graph3D). `events` is the full store slice; we only act
|
||||
// on ones newer than the last we saw (tracked by object identity + a small
|
||||
// dedupe window on timestamp is unnecessary — the store slices FIFO).
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private lastSeenTop: VestigeEvent | null = null;
|
||||
|
||||
ingest(events: VestigeEvent[]): void {
|
||||
if (events.length === 0) return;
|
||||
// Find the boundary: everything above lastSeenTop is new.
|
||||
let newCount = events.length;
|
||||
if (this.lastSeenTop) {
|
||||
const idx = events.indexOf(this.lastSeenTop);
|
||||
if (idx >= 0) newCount = idx;
|
||||
}
|
||||
if (newCount === 0) return;
|
||||
// Apply oldest→newest (reverse, since events[] is newest-first).
|
||||
for (let i = newCount - 1; i >= 0; i--) {
|
||||
this.decodeAndArm(events[i], this.engine.totalFrames);
|
||||
}
|
||||
this.lastSeenTop = events[0];
|
||||
}
|
||||
|
||||
/** Decode one wire event and, if it's a hero trigger, arm it. */
|
||||
private decodeAndArm(ev: VestigeEvent, simFrame: number): void {
|
||||
const data = (ev.data ?? {}) as Record<string, unknown>;
|
||||
switch (ev.type) {
|
||||
case 'MemorySuppressed': {
|
||||
const id = str(data.id);
|
||||
if (!id || !this.indexById.has(id)) return;
|
||||
this.arm({
|
||||
kind: LIVE_KIND.firewall,
|
||||
startFrame: simFrame,
|
||||
targetId: id,
|
||||
relatedIds: this.neighborsOf(id),
|
||||
pairs: [],
|
||||
scalar: num(data.estimated_cascade)
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'DeepReferenceCompleted': {
|
||||
const pairs = decodePairs(data.contradiction_pairs);
|
||||
// Contradiction pairs both present in the field → firewall.
|
||||
const livePairs = pairs.filter(
|
||||
([a, b]) => this.indexById.has(a) && this.indexById.has(b)
|
||||
);
|
||||
if (livePairs.length > 0) {
|
||||
const target = livePairs[0][0];
|
||||
this.arm({
|
||||
kind: LIVE_KIND.firewall,
|
||||
startFrame: simFrame,
|
||||
targetId: target,
|
||||
relatedIds: livePairs.flatMap((p) => p).filter((x) => x !== target),
|
||||
pairs: livePairs,
|
||||
scalar: livePairs.length
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Otherwise a recall: light the backward causal path (Phase 4).
|
||||
const primary = str(data.primary_id);
|
||||
const supporting = strArr(data.supporting_ids).filter((x) => this.indexById.has(x));
|
||||
if (primary && this.indexById.has(primary)) {
|
||||
this.arm({
|
||||
kind: LIVE_KIND.causalRecall,
|
||||
startFrame: simFrame,
|
||||
targetId: primary,
|
||||
relatedIds: supporting,
|
||||
pairs: [],
|
||||
scalar: num(data.confidence)
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'BackfillFired':
|
||||
case 'CausalReceipt': {
|
||||
// Phase 4 dedicated event: carries the backward causal path.
|
||||
const path = strArr(data.path_ids ?? data.causal_path).filter((x) =>
|
||||
this.indexById.has(x)
|
||||
);
|
||||
const target = str(data.target_id ?? data.effect_id) || path[0];
|
||||
if (target && this.indexById.has(target)) {
|
||||
this.arm({
|
||||
kind: LIVE_KIND.causalRecall,
|
||||
startFrame: simFrame,
|
||||
targetId: target,
|
||||
relatedIds: path.filter((x) => x !== target),
|
||||
pairs: [],
|
||||
scalar: path.length
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'DreamStarted': {
|
||||
this.dreamOpen = true;
|
||||
this.arm({
|
||||
kind: LIVE_KIND.dreamStorm,
|
||||
startFrame: simFrame,
|
||||
targetId: '',
|
||||
relatedIds: [],
|
||||
pairs: [],
|
||||
scalar: num(data.memory_count)
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'DreamCompleted': {
|
||||
this.dreamOpen = false;
|
||||
// Let the storm settle: keep the current active event but mark it
|
||||
// finite from now (drain will fade it out over ~120 frames).
|
||||
if (this.active && this.active.kind === LIVE_KIND.dreamStorm) {
|
||||
this.active.startFrame = simFrame - 500; // jump near the tail
|
||||
}
|
||||
break;
|
||||
}
|
||||
// ConnectionDiscovered is consumed by the dream-storm edge appender
|
||||
// in ObservatoryStage directly (it needs the renderer's setEdges);
|
||||
// the bridge just keeps the storm energy high while they stream.
|
||||
case 'ConnectionDiscovered': {
|
||||
if (this.dreamOpen && this.active?.kind === LIVE_KIND.dreamStorm) {
|
||||
this.active.scalar += 1; // more connections → more agitation
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private arm(ev: DecodedEvent): void {
|
||||
this.active = ev;
|
||||
this.eventsSeen++;
|
||||
}
|
||||
|
||||
/** Real graph neighbors of a node id (for the firewall quarantine ring). */
|
||||
private neighborsOf(id: string): string[] {
|
||||
const idx = this.indexById.get(id);
|
||||
if (idx === undefined) return [];
|
||||
const out: string[] = [];
|
||||
for (const e of this.graph.edges) {
|
||||
if (e.sourceIndex === idx) out.push(this.graph.nodes[e.targetIndex].id);
|
||||
else if (e.targetIndex === idx) out.push(this.graph.nodes[e.sourceIndex].id);
|
||||
if (out.length >= 12) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Per-frame drain — the engine's preFrameHook. Allocation-free.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
drain(simFrame: number): void {
|
||||
const p = this.engine.params;
|
||||
|
||||
// --- Phase 1: live FSRS decay (recompute retrievability on the true
|
||||
// curve). Throttled to every 6 frames (10Hz) — decay drifts far slower
|
||||
// than a frame, and the scrubber jumps are applied immediately below. ---
|
||||
const proj = this.projectionDays();
|
||||
p[PARAM_IDX.projectionDays] = proj;
|
||||
if (this.hasLiveDecay && (simFrame - this.lastDecayFrame >= 6 || proj !== this.lastProj)) {
|
||||
this.recomputeDecay(proj);
|
||||
this.lastDecayFrame = simFrame;
|
||||
this.lastProj = proj;
|
||||
}
|
||||
|
||||
// --- Live event envelope: lanes 12..14. ---
|
||||
if (this.active) {
|
||||
const dur = LIVE_DURATION[this.active.kind] ?? 300;
|
||||
const elapsed = simFrame - this.active.startFrame;
|
||||
const openEnded = dur === 0 && this.dreamOpen;
|
||||
if (!openEnded && dur > 0 && elapsed > dur + 140) {
|
||||
// Envelope finished (+ fade tail) → back to calm.
|
||||
this.active = null;
|
||||
p[PARAM_IDX.liveKind] = LIVE_KIND.none;
|
||||
p[PARAM_IDX.liveEnergy] = 0;
|
||||
} else {
|
||||
p[PARAM_IDX.liveKind] = this.active.kind;
|
||||
p[PARAM_IDX.liveStartFrame] = this.active.startFrame;
|
||||
p[PARAM_IDX.liveEnergy] = this.energyEnvelope(this.active, elapsed, openEnded);
|
||||
}
|
||||
} else {
|
||||
p[PARAM_IDX.liveKind] = LIVE_KIND.none;
|
||||
p[PARAM_IDX.liveEnergy] = 0;
|
||||
}
|
||||
|
||||
this.onApply?.({
|
||||
simFrame,
|
||||
activeKind: p[PARAM_IDX.liveKind],
|
||||
eventsSeen: this.eventsSeen
|
||||
});
|
||||
}
|
||||
|
||||
private lastProj = -1;
|
||||
|
||||
/** 0..1 agitation envelope for the active event at `elapsed` frames. */
|
||||
private energyEnvelope(ev: DecodedEvent, elapsed: number, openEnded: boolean): number {
|
||||
if (elapsed < 0) return 0;
|
||||
if (openEnded) {
|
||||
// Dream storm: ramp in over 45f, hold high, agitation scales with
|
||||
// how many connections have streamed in (scalar).
|
||||
const ramp = Math.min(1, elapsed / 45);
|
||||
return ramp * Math.min(1.4, 0.6 + ev.scalar * 0.04);
|
||||
}
|
||||
const dur = LIVE_DURATION[ev.kind] ?? 300;
|
||||
const attack = Math.min(1, elapsed / 24);
|
||||
const release = 1 - Math.max(0, (elapsed - dur) / 140);
|
||||
return Math.max(0, attack * Math.min(1, release));
|
||||
}
|
||||
|
||||
/** Recompute per-node live retrievability and push to the GPU (Phase 1). */
|
||||
private recomputeDecay(projectionDays: number): void {
|
||||
const nowMs = this.engine.wallNowMs;
|
||||
const nodes = this.graph.nodes;
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const n = nodes[i];
|
||||
this.retention[i] =
|
||||
n.stability !== undefined && n.lastAccessed
|
||||
? liveRetrievability(n.stability, n.lastAccessed, nowMs, projectionDays)
|
||||
: n.retention;
|
||||
}
|
||||
this.renderer.uploadLiveRetention(this.retention);
|
||||
}
|
||||
|
||||
/** Force a decay recompute now (e.g. the scrubber moved). */
|
||||
refreshDecay(): void {
|
||||
if (this.hasLiveDecay) this.recomputeDecay(this.projectionDays());
|
||||
}
|
||||
}
|
||||
|
||||
// --- tiny decoders (no allocation beyond the returned value) ---
|
||||
|
||||
function str(v: unknown): string {
|
||||
return typeof v === 'string' ? v : '';
|
||||
}
|
||||
function num(v: unknown): number {
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : 0;
|
||||
}
|
||||
function strArr(v: unknown): string[] {
|
||||
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
|
||||
}
|
||||
function decodePairs(v: unknown): [string, string][] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
const out: [string, string][] = [];
|
||||
for (const pair of v) {
|
||||
if (Array.isArray(pair) && pair.length >= 2 && typeof pair[0] === 'string' && typeof pair[1] === 'string') {
|
||||
out.push([pair[0], pair[1]]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -47,6 +47,13 @@ export class NodeRenderer implements FramePass {
|
|||
private simBindGroup: GPUBindGroup | null = null;
|
||||
private pathBuffer: GPUBuffer | null = null;
|
||||
|
||||
// v2.3 living field — per-node LIVE retrievability (one f32/node), recomputed
|
||||
// on the real FSRS curve each frame by the LiveBridge and read by the sim
|
||||
// compute pass to overwrite vel_retention.w. Created at upload with node
|
||||
// count; seeded to the static retention snapshot so a pre-bridge field is
|
||||
// unchanged.
|
||||
private liveRetentionBuffer: GPUBuffer | null = null;
|
||||
|
||||
// Path edge wavefront (Increment 6)
|
||||
private pathPipeline: GPURenderPipeline | null = null;
|
||||
private pathBindGroup: GPUBindGroup | null = null;
|
||||
|
|
@ -102,6 +109,20 @@ export class NodeRenderer implements FramePass {
|
|||
});
|
||||
device.queue.writeBuffer(this.edgeBuffer, 0, edgeData.buffer as ArrayBuffer);
|
||||
|
||||
// v2.3 live retrievability — one f32 per node, seeded to each node's
|
||||
// static retention so the field is unchanged until the LiveBridge starts
|
||||
// recomputing on the real FSRS curve. Padded to ≥16 bytes so a tiny
|
||||
// graph still makes a valid storage buffer.
|
||||
const liveRet = new Float32Array(Math.max(nodeCount, 4));
|
||||
for (let i = 0; i < nodeCount; i++) liveRet[i] = graph.nodes[i].retention;
|
||||
this.liveRetentionBuffer?.destroy();
|
||||
this.liveRetentionBuffer = device.createBuffer({
|
||||
label: 'observatory-live-retention',
|
||||
size: liveRet.byteLength,
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST
|
||||
});
|
||||
device.queue.writeBuffer(this.liveRetentionBuffer, 0, liveRet.buffer as ArrayBuffer);
|
||||
|
||||
// Recall path: deterministic story beats → PathStep storage buffer.
|
||||
// (Skipped for demo modes with their own choreography — the buffer is
|
||||
// still created so the sim bind group stays valid, just with 0 steps.)
|
||||
|
|
@ -155,6 +176,21 @@ export class NodeRenderer implements FramePass {
|
|||
this.createPipeline(device);
|
||||
}
|
||||
|
||||
/**
|
||||
* v2.3 living field — push freshly-computed per-node live retrievability to
|
||||
* the GPU. The LiveBridge calls this (throttled) with a Float32Array whose
|
||||
* length is the node count; the sim compute pass reads it to overwrite each
|
||||
* node's vel_retention.w, so render-nodes dims every memory on its REAL FSRS
|
||||
* curve. One writeBuffer, no pipeline rebuild — the buffer is already bound.
|
||||
*/
|
||||
uploadLiveRetention(data: Float32Array): void {
|
||||
const device = this.engine.gpuDevice;
|
||||
if (!device || !this.liveRetentionBuffer) return;
|
||||
const n = Math.min(data.length, this.nodeCount);
|
||||
if (n <= 0) return;
|
||||
device.queue.writeBuffer(this.liveRetentionBuffer, 0, data.buffer as ArrayBuffer, 0, n * 4);
|
||||
}
|
||||
|
||||
private createPipeline(device: GPUDevice): void {
|
||||
if (!this.engine.paramsBuffer || !this.cameraBuffer || !this.nodeBuffer) return;
|
||||
|
||||
|
|
@ -177,6 +213,9 @@ export class NodeRenderer implements FramePass {
|
|||
if (this.edgeBuffer) {
|
||||
simEntries.push({ binding: 3, resource: { buffer: this.edgeBuffer } });
|
||||
}
|
||||
if (this.liveRetentionBuffer) {
|
||||
simEntries.push({ binding: 4, resource: { buffer: this.liveRetentionBuffer } });
|
||||
}
|
||||
this.simBindGroup = device.createBindGroup({
|
||||
label: 'observatory-recall-sim-bind',
|
||||
layout: this.simPipeline.getBindGroupLayout(0),
|
||||
|
|
@ -402,10 +441,12 @@ export class NodeRenderer implements FramePass {
|
|||
this.edgeBuffer?.destroy();
|
||||
this.cameraBuffer?.destroy();
|
||||
this.pathBuffer?.destroy();
|
||||
this.liveRetentionBuffer?.destroy();
|
||||
this.nodeBuffer = null;
|
||||
this.edgeBuffer = null;
|
||||
this.cameraBuffer = null;
|
||||
this.pathBuffer = null;
|
||||
this.liveRetentionBuffer = null;
|
||||
this.pipeline = null;
|
||||
this.bindGroup = null;
|
||||
this.simPipeline = null;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
* frames 510–719: stabilization (hold, then reset)
|
||||
*
|
||||
* All time terms are integer-cycles per 720 frames so the loop seam is
|
||||
* invisible. Capture mode (params._pad == 1.0) skips integration.
|
||||
* invisible. Capture mode (params.capture_mode == 1.0) skips integration.
|
||||
*
|
||||
* Particle layout (16 floats / 64 bytes per particle):
|
||||
* start_life : xyz start position, w phase offset (stagger)
|
||||
|
|
@ -33,7 +33,11 @@ struct Params {
|
|||
brightness: f32,
|
||||
demo_id: f32,
|
||||
time: f32,
|
||||
_pad: f32,
|
||||
capture_mode: f32,
|
||||
live_kind: f32,
|
||||
live_start_frame: f32,
|
||||
live_energy: f32,
|
||||
projection_days: f32,
|
||||
};
|
||||
|
||||
// 16 floats / 64 bytes per particle (matches birth-plan.ts layout).
|
||||
|
|
@ -54,9 +58,9 @@ fn birth_compute(@builtin(global_invocation_id) id: vec3<u32>) {
|
|||
return;
|
||||
}
|
||||
|
||||
// Capture mode (params._pad == 1.0): skip physics integration.
|
||||
// Capture mode (params.capture_mode == 1.0): skip physics integration.
|
||||
// The storage-buffer state stays frozen at initial upload values.
|
||||
if (params._pad == 1.0) {
|
||||
if (params.capture_mode == 1.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,11 @@ struct Params {
|
|||
brightness: f32,
|
||||
demo_id: f32,
|
||||
time: f32,
|
||||
_pad: f32,
|
||||
capture_mode: f32,
|
||||
live_kind: f32,
|
||||
live_start_frame: f32,
|
||||
live_energy: f32,
|
||||
projection_days: f32,
|
||||
};
|
||||
|
||||
struct Node {
|
||||
|
|
|
|||
|
|
@ -39,7 +39,11 @@ struct Params {
|
|||
brightness: f32,
|
||||
demo_id: f32,
|
||||
time: f32,
|
||||
_pad: f32,
|
||||
capture_mode: f32,
|
||||
live_kind: f32,
|
||||
live_start_frame: f32,
|
||||
live_energy: f32,
|
||||
projection_days: f32,
|
||||
};
|
||||
|
||||
struct Node {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,11 @@ struct Params {
|
|||
brightness: f32,
|
||||
demo_id: f32,
|
||||
time: f32,
|
||||
_pad: f32,
|
||||
capture_mode: f32,
|
||||
live_kind: f32,
|
||||
live_start_frame: f32,
|
||||
live_energy: f32,
|
||||
projection_days: f32,
|
||||
};
|
||||
|
||||
struct Camera {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,11 @@ struct Params {
|
|||
brightness: f32,
|
||||
demo_id: f32,
|
||||
time: f32,
|
||||
_pad: f32,
|
||||
capture_mode: f32,
|
||||
live_kind: f32,
|
||||
live_start_frame: f32,
|
||||
live_energy: f32,
|
||||
projection_days: f32,
|
||||
};
|
||||
|
||||
struct Camera {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,11 @@ struct Params {
|
|||
brightness: f32,
|
||||
demo_id: f32,
|
||||
time: f32,
|
||||
_pad: f32,
|
||||
capture_mode: f32,
|
||||
live_kind: f32,
|
||||
live_start_frame: f32,
|
||||
live_energy: f32,
|
||||
projection_days: f32,
|
||||
};
|
||||
|
||||
struct Camera {
|
||||
|
|
|
|||
|
|
@ -47,7 +47,11 @@ struct Params {
|
|||
brightness: f32,
|
||||
demo_id: f32,
|
||||
time: f32,
|
||||
_pad: f32,
|
||||
capture_mode: f32,
|
||||
live_kind: f32,
|
||||
live_start_frame: f32,
|
||||
live_energy: f32,
|
||||
projection_days: f32,
|
||||
};
|
||||
|
||||
struct Node {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,11 @@ struct Params {
|
|||
brightness: f32,
|
||||
demo_id: f32,
|
||||
time: f32,
|
||||
_pad: f32,
|
||||
capture_mode: f32,
|
||||
live_kind: f32,
|
||||
live_start_frame: f32,
|
||||
live_energy: f32,
|
||||
projection_days: f32,
|
||||
};
|
||||
|
||||
struct Node {
|
||||
|
|
@ -49,6 +53,10 @@ struct Node {
|
|||
@group(0) @binding(2) var<storage, read> path: array<vec4<u32>>;
|
||||
// x source index, y target node index (Increment 7: force simulation edges)
|
||||
@group(0) @binding(3) var<storage, read> edges: array<vec2<u32>>;
|
||||
// v2.3 living field — per-node LIVE retrievability (real FSRS curve, recomputed
|
||||
// on the CPU by the LiveBridge). One f32 per node. read to overwrite
|
||||
// vel_retention.w so render-nodes dims each memory on its true forgetting curve.
|
||||
@group(0) @binding(4) var<storage, read> live_retention: array<f32>;
|
||||
|
||||
// --- Force-simulation helpers (Increment 7) ---
|
||||
|
||||
|
|
@ -100,12 +108,25 @@ fn recall_sim(@builtin(global_invocation_id) id: vec3<u32>) {
|
|||
// Write recall intensity (existing behavior preserved).
|
||||
node.demo.x = intensity;
|
||||
|
||||
// v2.3 LIVE FSRS decay — overwrite retention with the real forgetting-curve
|
||||
// value the LiveBridge computed for this node on the CPU. This is the #1
|
||||
// moat: render-nodes already dims by vel_retention.w (line ~183), so writing
|
||||
// the true retrievability here makes every memory visibly forget on its own
|
||||
// curve. Guarded so a graph with no live-decay data (all zeros) keeps its
|
||||
// static snapshot instead of collapsing to black.
|
||||
if (i < arrayLength(&live_retention)) {
|
||||
let lr = live_retention[i];
|
||||
if (lr > 0.0) {
|
||||
node.vel_retention = vec4<f32>(node.vel_retention.xyz, lr);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Increment 7: force simulation ---
|
||||
|
||||
// Capture mode (params._pad == 1.0): skip physics integration entirely.
|
||||
// The storage-buffer state stays frozen at initial upload values,
|
||||
// making same URL + frame → identical pixels (spec §4 Inc 9).
|
||||
if (params._pad == 0.0) {
|
||||
// Capture mode (params.capture_mode == 1.0): skip physics integration
|
||||
// entirely. The storage-buffer state stays frozen at initial upload
|
||||
// values, making same URL + frame → identical pixels (spec §4 Inc 9).
|
||||
if (params.capture_mode == 0.0) {
|
||||
// 7B: center anchor — center node never moves.
|
||||
// (WGSL forbids swizzle stores — reconstruct the vec4, preserving .w.)
|
||||
if (is_center) {
|
||||
|
|
@ -155,7 +176,7 @@ fn recall_sim(@builtin(global_invocation_id) id: vec3<u32>) {
|
|||
node.vel_retention = vec4<f32>(vel, node.vel_retention.w);
|
||||
node.pos_radius = vec4<f32>(pos + vel, node.pos_radius.w);
|
||||
}
|
||||
// When capture_mode (params._pad == 1.0), node is NOT written back —
|
||||
// When capture_mode (params.capture_mode == 1.0), node is NOT written back —
|
||||
// the storage buffer retains its initial upload values, guaranteeing
|
||||
// deterministic pixels for the same frame index.
|
||||
nodes[i] = node;
|
||||
|
|
|
|||
|
|
@ -104,9 +104,57 @@ export const PATH_KIND = {
|
|||
// [8] brightness (from graphState)
|
||||
// [9] demoId (DemoMode index)
|
||||
// [10] time (fixed sim seconds = frame / fps; NOT wall clock)
|
||||
// [11] _pad
|
||||
export const PARAMS_FLOATS = 12;
|
||||
export const PARAMS_BYTES = PARAMS_FLOATS * 4; // 48 (multiple of 16)
|
||||
// [11] captureMode (1.0 = ?frame=N freeze — simulate.wgsl skips physics)
|
||||
//
|
||||
// --- LIVE lanes (v2.3: the field driven by the real backend event stream) ---
|
||||
// These carry the state of the ONE active live cognitive event so every
|
||||
// shader can react to real backend data instead of the deterministic
|
||||
// DemoClock. All are 0.0 at rest (a calm field), so a build with no live
|
||||
// bridge is pixel-identical to before.
|
||||
// [12] liveKind (LiveKind index — 0 none, see LIVE_KIND below)
|
||||
// [13] liveStartFrame (absolute sim frame the live event fired — its
|
||||
// animation envelope anchor; NOT the wrapped loop
|
||||
// frame, so live events never pop at the 720 seam)
|
||||
// [14] liveEnergy (0..1+ global agitation — dream storm loosens the
|
||||
// sim, firewall shock rings the field, etc.)
|
||||
// [15] projectionDays (Phase 1 forward-scrub: added to every node's real
|
||||
// FSRS elapsed so decay is legible in one session)
|
||||
export const PARAMS_FLOATS = 16;
|
||||
export const PARAMS_BYTES = PARAMS_FLOATS * 4; // 64 (multiple of 16)
|
||||
|
||||
/**
|
||||
* Live-event kinds written into params[12] (liveKind). The field's shaders
|
||||
* branch on this the same way they branch on demo_id, but it is driven by the
|
||||
* real WebSocket stream, not the DemoClock. 0 = nothing live (calm field).
|
||||
*/
|
||||
export const LIVE_KIND = {
|
||||
none: 0,
|
||||
firewall: 1, // MemorySuppressed / contradiction quarantine on camera
|
||||
dreamStorm: 2, // DreamStarted..DreamCompleted consolidation storm
|
||||
causalRecall: 3, // BackfillFired / DeepReferenceCompleted backward path
|
||||
birth: 4 // MemoryCreated (deferred hero — reserved)
|
||||
} as const;
|
||||
export type LiveKind = (typeof LIVE_KIND)[keyof typeof LIVE_KIND];
|
||||
|
||||
/** Param array indices for the live lanes (mirror the WGSL Params order). */
|
||||
export const PARAM_IDX = {
|
||||
frame: 0,
|
||||
loopPhase: 1,
|
||||
nodeCount: 2,
|
||||
edgeCount: 3,
|
||||
pathCount: 4,
|
||||
pulse: 5,
|
||||
viewportW: 6,
|
||||
viewportH: 7,
|
||||
brightness: 8,
|
||||
demoId: 9,
|
||||
time: 10,
|
||||
captureMode: 11,
|
||||
liveKind: 12,
|
||||
liveStartFrame: 13,
|
||||
liveEnergy: 14,
|
||||
projectionDays: 15
|
||||
} as const;
|
||||
|
||||
export function demoModeId(mode: DemoMode): number {
|
||||
const i = DEMO_MODES.indexOf(mode);
|
||||
|
|
@ -125,6 +173,15 @@ export interface ObservatoryNode {
|
|||
tags: string[];
|
||||
isCenter: boolean;
|
||||
suppressed: boolean;
|
||||
/**
|
||||
* v2.3 living field — real FSRS state. `stability` (days) + `lastAccessed`
|
||||
* (ISO) let the live decay pass recompute retrievability every frame on the
|
||||
* true forgetting curve (fsrs.ts). Undefined when the backend serializer
|
||||
* predates the get_graph stability/lastAccessed add — the field then holds
|
||||
* the static `retention` snapshot instead (graceful, still honest).
|
||||
*/
|
||||
stability?: number;
|
||||
lastAccessed?: string;
|
||||
}
|
||||
|
||||
export interface ObservatoryEdge {
|
||||
|
|
@ -152,7 +209,9 @@ export function toObservatoryNode(n: GraphNode, index: number): ObservatoryNode
|
|||
retention: typeof n.retention === 'number' ? n.retention : 0,
|
||||
tags: Array.isArray(n.tags) ? n.tags : [],
|
||||
isCenter: !!n.isCenter,
|
||||
suppressed: (n.suppression_count ?? 0) > 0
|
||||
suppressed: (n.suppression_count ?? 0) > 0,
|
||||
stability: typeof n.stability === 'number' ? n.stability : undefined,
|
||||
lastAccessed: typeof n.lastAccessed === 'string' ? n.lastAccessed : undefined
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -77,6 +77,12 @@ export interface GraphNode {
|
|||
// v2.0.5 Active Forgetting — top-down suppression state
|
||||
suppression_count?: number;
|
||||
suppressed_at?: string;
|
||||
// v2.3 living field — real FSRS state so the graph can render LIVE decay
|
||||
// (retrievability recomputed each frame on the true forgetting curve).
|
||||
// stability in days; lastAccessed is an ISO timestamp. Optional so an old
|
||||
// serializer (pre-2026-07) still parses.
|
||||
stability?: number;
|
||||
lastAccessed?: string;
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
|
|
@ -167,6 +173,11 @@ export type VestigeEventType =
|
|||
| 'ActivationSpread'
|
||||
| 'ImportanceScored'
|
||||
| 'DeepReferenceCompleted'
|
||||
// v2.3 living field — RSB causal recall receipt (Phase 4): a failure-
|
||||
// triggered backward-only causal path with shared-entity join keys, so the
|
||||
// field lights the REAL cause chain instead of a random pulse.
|
||||
| 'BackfillFired'
|
||||
| 'CausalReceipt'
|
||||
| 'HookVerdictRecorded'
|
||||
| 'TraceEvent'
|
||||
| 'MemoryPrOpened'
|
||||
|
|
|
|||
|
|
@ -320,6 +320,7 @@ disown</code>
|
|||
<div class="absolute inset-0">
|
||||
<ObservatoryStage
|
||||
embedded
|
||||
live
|
||||
chrome="none"
|
||||
demo="recall-path"
|
||||
showSwitcher={false}
|
||||
|
|
|
|||
|
|
@ -1239,6 +1239,12 @@ pub async fn get_graph(
|
|||
"createdAt": n.created_at.to_rfc3339(),
|
||||
"updatedAt": n.updated_at.to_rfc3339(),
|
||||
"isCenter": n.id == center_id,
|
||||
// v2.3 living field — real FSRS state so the dashboard graph can
|
||||
// render LIVE per-memory decay (retrievability recomputed each
|
||||
// frame on the true forgetting curve). Already emitted by the
|
||||
// single-node handlers; the field just needed it per-node too.
|
||||
"stability": n.stability,
|
||||
"lastAccessed": n.last_accessed.to_rfc3339(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue