feat(observatory): contradiction firewall fires on real events (Phase 2)

The crimson quarantine firewall was hard-locked to the deterministic demo
clock. Now it fires on real cognitive events — a MemorySuppressed or a
DeepReferenceCompleted contradiction pair — targeting the REAL memory and
its REAL graph neighbors, on camera.

- firewall-plan: buildLiveFirewallPlan(graph, seed, intruderIndex) reuses
  all the deterministic machinery (real-layout shock delays, real severed
  neighbors, spine, verdict) but takes the intruder from the live event.
- firewall.wgsl + render-nodes.wgsl: dual-gate on demo_id==4 OR
  live_kind==1, driven by live_frame (frames since the event) so the
  720-frame beat map plays once from the event, not the loop clock.
- firewall-renderer: rearm(plan) rebuilds the fire buffer for a live
  target; compute() gates on demo OR live.
- live-bridge: lazily builds + rearms a live FirewallRenderer on a
  firewall event; onFirewall callback surfaces the verdict.
- ObservatoryStage: crimson "threat quarantined" verdict card naming the
  real memory, auto-clears after the choreography.
- live lane [13] repurposed liveStartFrame -> liveFrame (event-relative).

Verified live: fired 3 real suppressions on the 1241-memory brain — each
raised the crimson verdict naming the real memory ("ProtocolGate Lane 4
design decision", cascade ~54), the field ran the quarantine, the forget
counter incremented 129->132, then all 3 reversed cleanly. check 0 err,
1033 tests, build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-07-08 21:49:22 -07:00
parent addbf260f1
commit d7f5657bc7
13 changed files with 184 additions and 24 deletions

View file

@ -96,6 +96,11 @@
let projectionDays = $state(0);
let liveBridge: LiveBridge | null = null;
let liveDecayReady = $state(false);
// Live contradiction-firewall verdict — set when a real MemorySuppressed /
// contradiction event quarantines a memory on camera. Held ~7s then fades.
let liveFirewallLabel = $state('');
let liveFirewallAt = $state(0);
let liveFirewallVisible = $derived(liveFirewallLabel !== '' && liveFirewallAt > 0);
// GPU picking — screen px → NDC → NodeRenderer.pickAt (one readback/click).
let canvasLayerEl: HTMLDivElement | null = $state(null);
@ -297,7 +302,12 @@
engine,
renderer,
graph: renderer.graph,
projectionDays: () => projectionDays
seed,
projectionDays: () => projectionDays,
onFirewall: (info) => {
liveFirewallLabel = info.intruderLabel;
liveFirewallAt = Date.now();
}
});
liveDecayReady = liveBridge.liveDecayAvailable;
engine.setPreFrameHook((simFrame) => liveBridge?.drain(simFrame));
@ -324,6 +334,17 @@
liveBridge?.refreshDecay();
});
// Auto-clear the live firewall verdict ~7s after it fires (matches the
// quarantine choreography length so the card leaves with the crimson ring).
$effect(() => {
if (!liveFirewallAt) return;
const t = setTimeout(() => {
liveFirewallLabel = '';
liveFirewallAt = 0;
}, 7000);
return () => clearTimeout(t);
});
onMount(() => {
loadGraph();
});
@ -359,6 +380,28 @@
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 — LIVE contradiction firewall verdict. Fires only
when a real MemorySuppressed / contradiction event quarantines a
memory on camera; the crimson quarantine ring plays on the field
itself (firewall.wgsl live path). Crimson tone = the immune response. -->
{#if live && liveFirewallVisible}
<div
class="absolute top-20 left-1/2 -translate-x-1/2 pointer-events-none
flex flex-col items-center gap-1 px-5 py-3 rounded-xl border border-[#ff2d55]/40
bg-[#1a0508]/85 backdrop-blur-sm text-center enter"
>
<div class="font-mono text-[11px] tracking-[0.2em] text-[#ff5c78] uppercase">
⬤ threat quarantined
</div>
<div class="font-mono text-[13px] text-[#ffd0d8] max-w-sm truncate">
{liveFirewallLabel}
</div>
<div class="font-mono text-[10px] tracking-wide text-[#ff5c78]/70">
memory held in review · Memory PR opened
</div>
</div>
{/if}
<!-- 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

View file

@ -252,6 +252,11 @@ export function firewallEnvelopes(
const UINTS_PER_STEP = 4;
/** An idle (viable:false) firewall plan — the field breathes, nothing fires. */
export function emptyFirewallPlan(nodeCount: number): FirewallPlan {
return emptyPlan(nodeCount);
}
function emptyPlan(nodeCount: number): FirewallPlan {
return {
viable: false,
@ -276,8 +281,36 @@ function emptyPlan(nodeCount: number): FirewallPlan {
* viable:false the field breathes, nothing pretends to be a threat.
*/
export function buildFirewallPlan(graph: ObservatoryGraph, seed: string): FirewallPlan {
return buildFirewallPlanFor(graph, seed, pickIntruderIndex(graph));
}
/**
* v2.3 living field build a firewall plan for a SPECIFIC intruder (a real
* MemorySuppressed target, or a real contradiction-pair member) instead of the
* deterministic demo pick. Everything downstream (shock delays from the real
* layout, severed real neighbors, spine beats, verdict) is identical only the
* intruder is supplied by the live event. The neighbors are the intruder's REAL
* graph edges (pickSeveredNeighbors), so the quarantine ring severs true
* connections, never invented ones. Returns viable:false if the intruder isn't
* in the field (nothing pretends).
*/
export function buildLiveFirewallPlan(
graph: ObservatoryGraph,
seed: string,
intruderIndex: number
): FirewallPlan {
if (intruderIndex < 0 || intruderIndex >= graph.nodes.length) {
return emptyPlan(graph.nodes.length);
}
return buildFirewallPlanFor(graph, seed, intruderIndex);
}
function buildFirewallPlanFor(
graph: ObservatoryGraph,
seed: string,
intruderIndex: number
): FirewallPlan {
const n = graph.nodes.length;
const intruderIndex = pickIntruderIndex(graph);
if (n === 0 || intruderIndex < 0) return emptyPlan(n);
// Shock delays come from the REAL layout (rescue-plan.layoutPositions —

View file

@ -91,9 +91,37 @@ export class FirewallRenderer implements FramePass {
});
}
/**
* v2.3 living field re-arm the firewall for a LIVE event with a fresh plan
* (a real MemorySuppressed / contradiction target + its real neighbors).
* Rebuilds the fire buffer + bind group so the choreography quarantines the
* true intruder. Safe to call any time after upload(); a non-viable plan
* disarms (the pass becomes a no-op until the next real event).
*/
rearm(plan: FirewallPlan): void {
this.plan = plan;
const device = this.engine.gpuDevice;
if (!device) return;
if (!plan.viable) {
this.pipeline = null;
this.bindGroup = null;
return;
}
this.upload();
}
/** Whether a viable plan is currently armed (live or demo). */
get armed(): boolean {
return this.plan.viable && !!this.pipeline && !!this.bindGroup;
}
/** FramePass — overwrite the four demo lanes for this frame (pure of frame). */
compute(encoder: GPUCommandEncoder): void {
if (this.engine.params[9] !== FIREWALL_DEMO_ID) return;
// Fire for the deterministic demo (demo index 4) OR a live firewall event
// (live_kind lane == LIVE_KIND.firewall == 1). Both drive the same shader.
const isDemo = this.engine.params[9] === FIREWALL_DEMO_ID;
const isLive = this.engine.params[12] === 1;
if (!isDemo && !isLive) return;
if (!this.pipeline || !this.bindGroup) return;
const n = this.nodeRenderer.nodeCountValue;
if (n === 0) return;

View file

@ -25,6 +25,8 @@ import type { NodeRenderer } from './node-renderer';
import type { VestigeEvent } from '$types';
import { LIVE_KIND, PARAM_IDX, type ObservatoryGraph } from './types';
import { liveRetrievability } from './fsrs';
import { FirewallRenderer } from './firewall-renderer';
import { buildLiveFirewallPlan, emptyFirewallPlan } from './firewall-plan';
/** A decoded live event, normalized off the wire's { type, data } envelope. */
interface DecodedEvent {
@ -52,6 +54,9 @@ export interface LiveBridgeDeps {
engine: ObservatoryEngine;
renderer: NodeRenderer;
graph: ObservatoryGraph;
/** Layout seed (matches NodeRenderer.upload) the firewall shock delays
* come from the REAL layout, so this must be the same seed the field used. */
seed: string;
/**
* 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
@ -60,14 +65,22 @@ export interface LiveBridgeDeps {
projectionDays?: () => number;
/** Dev/verification: mirror what the bridge applied, for assertions. */
onApply?: (info: { simFrame: number; activeKind: number; eventsSeen: number }) => void;
/** Fired when a live firewall arms — the host can surface a verdict card. */
onFirewall?: (info: { intruderLabel: string; startFrame: number }) => void;
}
export class LiveBridge {
private engine: ObservatoryEngine;
private renderer: NodeRenderer;
private graph: ObservatoryGraph;
private seed: string;
private projectionDays: () => number;
private onApply?: LiveBridgeDeps['onApply'];
private onFirewall?: LiveBridgeDeps['onFirewall'];
/** Live firewall pass constructed lazily on the first firewall event so a
* field that never sees a suppression pays nothing. */
private firewall: FirewallRenderer | null = null;
/** id → stable buffer index (from the ObservatoryGraph). */
private indexById: Map<string, number>;
@ -90,8 +103,10 @@ export class LiveBridge {
this.engine = deps.engine;
this.renderer = deps.renderer;
this.graph = deps.graph;
this.seed = deps.seed;
this.projectionDays = deps.projectionDays ?? (() => 0);
this.onApply = deps.onApply;
this.onFirewall = deps.onFirewall;
this.indexById = deps.graph.indexById;
const n = deps.graph.nodes.length;
@ -105,7 +120,7 @@ export class LiveBridge {
// 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.liveFrame] = 0;
p[PARAM_IDX.liveEnergy] = 0;
p[PARAM_IDX.projectionDays] = 0;
}
@ -248,6 +263,27 @@ export class LiveBridge {
private arm(ev: DecodedEvent): void {
this.active = ev;
this.eventsSeen++;
// Firewall: build a live plan for the REAL intruder + its real neighbors
// and (re)arm the quarantine pass. Lazily construct the renderer on first
// use so a field that never sees a suppression pays nothing. Pass order is
// safe: the bridge (and thus this renderer) is created AFTER the
// NodeRenderer, so firewall_choreo encodes after recall_sim.
if (ev.kind === LIVE_KIND.firewall) {
const intruderIndex = this.indexById.get(ev.targetId);
if (intruderIndex === undefined) return;
const plan = buildLiveFirewallPlan(this.graph, this.seed, intruderIndex);
if (!plan.viable) return;
if (!this.firewall) {
this.firewall = new FirewallRenderer({
engine: this.engine,
nodeRenderer: this.renderer,
plan: emptyFirewallPlan(this.graph.nodes.length)
});
}
this.firewall.rearm(plan);
this.onFirewall?.({ intruderLabel: plan.verdict.intruderLabel, startFrame: ev.startFrame });
}
}
/** Real graph neighbors of a node id (for the firewall quarantine ring). */
@ -293,7 +329,9 @@ export class LiveBridge {
p[PARAM_IDX.liveEnergy] = 0;
} else {
p[PARAM_IDX.liveKind] = this.active.kind;
p[PARAM_IDX.liveStartFrame] = this.active.startFrame;
// Event-relative frame: the live shader branches replay the
// choreography once from here, never riding the wrapped loop.
p[PARAM_IDX.liveFrame] = Math.max(0, elapsed);
p[PARAM_IDX.liveEnergy] = this.energyEnvelope(this.active, elapsed, openEnded);
}
} else {

View file

@ -35,7 +35,7 @@ struct Params {
time: f32,
capture_mode: f32,
live_kind: f32,
live_start_frame: f32,
live_frame: f32,
live_energy: f32,
projection_days: f32,
};

View file

@ -46,7 +46,7 @@ struct Params {
time: f32,
capture_mode: f32,
live_kind: f32,
live_start_frame: f32,
live_frame: f32,
live_energy: f32,
projection_days: f32,
};
@ -79,8 +79,13 @@ fn firewall_choreo(@builtin(global_invocation_id) id: vec3<u32>) {
if (i >= arrayLength(&fire)) {
return;
}
// Belt-and-braces atop the TS gate: firewall is demo index 4.
if (params.demo_id != 4.0) {
// Fire in TWO modes: the deterministic demo (demo_id == 4, wrapped loop
// frame) OR a LIVE event (live_kind == 1, driven by live_frame = frames
// since a real MemorySuppressed / contradiction fired). Anything else →
// no-op, and other grammars own the lanes.
let is_demo = params.demo_id == 4.0;
let is_live = params.live_kind == 1.0;
if (!is_demo && !is_live) {
return;
}
@ -90,7 +95,15 @@ fn firewall_choreo(@builtin(global_invocation_id) id: vec3<u32>) {
let is_sever = (packed & 0x200u) != 0u;
let k = f32((packed >> 10u) & 0xfu);
let f = params.frame;
// Live mode replays the SAME 720-frame beat map from the event: f =
// live_frame (clamped to the loop window), loop_phase derived from it so the
// integer-cycle sines still resolve. Demo mode reads the wrapped loop clock.
var f = params.frame;
var lp = params.loop_phase;
if (is_live) {
f = clamp(params.live_frame, 0.0, 719.0);
lp = f / 720.0;
}
var fy = 0.0;
var fw = 0.0;
@ -100,10 +113,10 @@ fn firewall_choreo(@builtin(global_invocation_id) id: vec3<u32>) {
// C¹ handoff into the membrane over 330-332 (the rise sweeps the flare
// band exactly once — the condensation read is intentional).
fy = env(f, 90.0, 96.0, 310.0, 332.0)
* (0.55 + 0.45 * sin(TAU * 36.0 * params.loop_phase));
* (0.55 + 0.45 * sin(TAU * 36.0 * lp));
// Membrane: sustained ring band [2.60..2.90], 12 integer cycles/loop.
fy = fy + env(f, 330.0, 352.0, 620.0, 680.0)
* (2.75 + 0.15 * sin(TAU * 12.0 * params.loop_phase));
* (2.75 + 0.15 * sin(TAU * 12.0 * lp));
// Source detonation as the front leaves.
fw = env(f, 148.0, 153.0, 162.0, 196.0);
} else {

View file

@ -41,7 +41,7 @@ struct Params {
time: f32,
capture_mode: f32,
live_kind: f32,
live_start_frame: f32,
live_frame: f32,
live_energy: f32,
projection_days: f32,
};

View file

@ -25,7 +25,7 @@ struct Params {
time: f32,
capture_mode: f32,
live_kind: f32,
live_start_frame: f32,
live_frame: f32,
live_energy: f32,
projection_days: f32,
};

View file

@ -24,7 +24,7 @@ struct Params {
time: f32,
capture_mode: f32,
live_kind: f32,
live_start_frame: f32,
live_frame: f32,
live_energy: f32,
projection_days: f32,
};
@ -125,11 +125,15 @@ fn vs_main(
let dy = node.demo.y;
let dz = node.demo.z;
let dw = node.demo.w;
// The firewall grammar fires for the deterministic demo (demo_id==4) AND for
// a LIVE contradiction/suppression event (live_kind==1). Both write the same
// demo lanes (firewall.wgsl), so the visual reads identically either way.
let firewall_active = params.demo_id == 4.0 || params.live_kind == 1.0;
var lane_swell = 0.0;
if (params.demo_id == 2.0) {
// salience-rescue: searchlight pop, wave shiver, shock bloom.
lane_swell = 0.5 * dy + 0.25 * dz + 0.9 * dw;
} else if (params.demo_id == 4.0) {
} else if (firewall_active) {
// firewall: intrusion flare pop (band (0..1]), membrane presence
// (band [2.6..2.9] via the range gate), crimson shock bloom.
lane_swell = 0.35 * min(dy, 1.0) + 0.3 * smoothstep(1.5, 2.2, dy) + 0.55 * dw;
@ -233,7 +237,7 @@ fn fs_main(in: VSOut) -> @location(0) vec4<f32> {
color = color + vec3<f32>(1.00, 0.16, 0.10) * in.demo_yzw.z * (core * 1.9 + halo * 1.1)
+ vec3<f32>(1.0, 0.85, 0.8) * core * in.demo_yzw.z * 0.4;
}
} else if (params.demo_id == 4.0) {
} else if (params.demo_id == 4.0 || params.live_kind == 1.0) {
// firewall: demo.y carries TWO value bands — intrusion flare (0..1]
// and membrane [2.6..2.9] — separated by range, one lane. demo.w is
// the crimson shock rim / sever blink. (demo_yzw = y/z/w lanes.)

View file

@ -24,7 +24,7 @@ struct Params {
time: f32,
capture_mode: f32,
live_kind: f32,
live_start_frame: f32,
live_frame: f32,
live_energy: f32,
projection_days: f32,
};

View file

@ -49,7 +49,7 @@ struct Params {
time: f32,
capture_mode: f32,
live_kind: f32,
live_start_frame: f32,
live_frame: f32,
live_energy: f32,
projection_days: f32,
};

View file

@ -35,7 +35,7 @@ struct Params {
time: f32,
capture_mode: f32,
live_kind: f32,
live_start_frame: f32,
live_frame: f32,
live_energy: f32,
projection_days: f32,
};

View file

@ -112,9 +112,10 @@ export const PATH_KIND = {
// 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)
// [13] liveFrame (frames SINCE the active live event fired — the
// event-relative animation frame the live shader
// branches read, so the choreography plays once from
// the event instead of riding the wrapped loop clock)
// [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
@ -151,7 +152,7 @@ export const PARAM_IDX = {
time: 10,
captureMode: 11,
liveKind: 12,
liveStartFrame: 13,
liveFrame: 13,
liveEnergy: 14,
projectionDays: 15
} as const;