@neuroverseos/nv-sim 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1857 @@
1
+ "use strict";
2
+ /**
3
+ * Live Visualizer — Round-by-round simulation viewer
4
+ *
5
+ * Streams simulation data to a local browser dashboard via Server-Sent Events.
6
+ * Watch rules fire, agents react, and governance reshape the system in real time.
7
+ *
8
+ * Usage:
9
+ * nv-sim visualize trading --live
10
+ */
11
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
12
+ if (k2 === undefined) k2 = k;
13
+ var desc = Object.getOwnPropertyDescriptor(m, k);
14
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
15
+ desc = { enumerable: true, get: function() { return m[k]; } };
16
+ }
17
+ Object.defineProperty(o, k2, desc);
18
+ }) : (function(o, m, k, k2) {
19
+ if (k2 === undefined) k2 = k;
20
+ o[k2] = m[k];
21
+ }));
22
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
23
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
24
+ }) : function(o, v) {
25
+ o["default"] = v;
26
+ });
27
+ var __importStar = (this && this.__importStar) || (function () {
28
+ var ownKeys = function(o) {
29
+ ownKeys = Object.getOwnPropertyNames || function (o) {
30
+ var ar = [];
31
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
32
+ return ar;
33
+ };
34
+ return ownKeys(o);
35
+ };
36
+ return function (mod) {
37
+ if (mod && mod.__esModule) return mod;
38
+ var result = {};
39
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
40
+ __setModuleDefault(result, mod);
41
+ return result;
42
+ };
43
+ })();
44
+ Object.defineProperty(exports, "__esModule", { value: true });
45
+ exports.startInteractiveServer = startInteractiveServer;
46
+ const http = __importStar(require("http"));
47
+ const fs = __importStar(require("fs"));
48
+ const path = __importStar(require("path"));
49
+ const swarmSimulation_1 = require("./swarmSimulation");
50
+ const worldBridge_1 = require("./worldBridge");
51
+ const narrativeInjection_1 = require("./narrativeInjection");
52
+ const scenarioLibrary_1 = require("./scenarioLibrary");
53
+ const liveAdapter_1 = require("./liveAdapter");
54
+ function getVariantsDir() {
55
+ return path.resolve(process.cwd(), "variants");
56
+ }
57
+ function ensureVariantsDir() {
58
+ const dir = getVariantsDir();
59
+ if (!fs.existsSync(dir)) {
60
+ fs.mkdirSync(dir, { recursive: true });
61
+ }
62
+ }
63
+ function slugify(name) {
64
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
65
+ }
66
+ function loadAllVariants() {
67
+ ensureVariantsDir();
68
+ const dir = getVariantsDir();
69
+ const files = fs.readdirSync(dir).filter(f => f.endsWith(".json")).sort();
70
+ return files.map(f => {
71
+ try {
72
+ return JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8"));
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ }).filter(Boolean);
78
+ }
79
+ function saveVariant(variant) {
80
+ ensureVariantsDir();
81
+ const filename = `${variant.id}.json`;
82
+ const filepath = path.join(getVariantsDir(), filename);
83
+ fs.writeFileSync(filepath, JSON.stringify(variant, null, 2), "utf-8");
84
+ return filepath;
85
+ }
86
+ function deleteVariant(variantId) {
87
+ const filepath = path.join(getVariantsDir(), `${variantId}.json`);
88
+ if (fs.existsSync(filepath)) {
89
+ fs.unlinkSync(filepath);
90
+ return true;
91
+ }
92
+ return false;
93
+ }
94
+ /**
95
+ * Start an interactive live server with world controls + live simulation.
96
+ *
97
+ * The UI sends POST /run-sim with world parameters.
98
+ * The server runs the simulation and streams results via SSE.
99
+ */
100
+ function startInteractiveServer(port, onReady) {
101
+ const clients = new Set();
102
+ let isRunning = false;
103
+ function broadcast(event) {
104
+ const data = `data: ${JSON.stringify(event)}\n\n`;
105
+ for (const client of clients) {
106
+ client.write(data);
107
+ }
108
+ }
109
+ function readBody(req) {
110
+ return new Promise((resolve) => {
111
+ let body = "";
112
+ req.on("data", (chunk) => { body += chunk.toString(); });
113
+ req.on("end", () => resolve(body));
114
+ });
115
+ }
116
+ function jsonResponse(res, status, data) {
117
+ res.writeHead(status, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
118
+ res.end(JSON.stringify(data));
119
+ }
120
+ async function runSimulation(config) {
121
+ const { resolveWorld } = await Promise.resolve().then(() => __importStar(require("./worldComparison")));
122
+ const { runGovernedComparison } = await Promise.resolve().then(() => __importStar(require("./governedSimulation")));
123
+ const resolved = resolveWorld(config.worldId);
124
+ const rounds = config.rounds ?? resolved.swarm.rounds ?? 5;
125
+ // Apply state variable overrides to world definition
126
+ const world = { ...resolved.world };
127
+ if (config.stateOverrides) {
128
+ const updatedVars = world.state_variables.map(sv => {
129
+ if (config.stateOverrides && sv.id in config.stateOverrides) {
130
+ return { ...sv, default_value: config.stateOverrides[sv.id] };
131
+ }
132
+ return sv;
133
+ });
134
+ world.state_variables = updatedVars;
135
+ }
136
+ // Resolve narrative events
137
+ let narrativeEvents = [];
138
+ if (config.scenarioId && scenarioLibrary_1.SCENARIO_LIBRARY[config.scenarioId]) {
139
+ narrativeEvents = (0, scenarioLibrary_1.resolveScenarioEvents)(scenarioLibrary_1.SCENARIO_LIBRARY[config.scenarioId]);
140
+ }
141
+ else if (config.injectEvents && config.injectEvents.length > 0) {
142
+ narrativeEvents = (0, narrativeInjection_1.parseInjectArgs)(["--inject", config.injectEvents.join(",")]);
143
+ }
144
+ const request = {
145
+ scenario: resolved.scenario,
146
+ stakeholders: resolved.stakeholders,
147
+ assumptions: resolved.assumptions,
148
+ constraints: resolved.constraints,
149
+ depth: resolved.depth,
150
+ swarm: { ...resolved.swarm, rounds },
151
+ };
152
+ // Send meta event
153
+ broadcast({
154
+ type: "meta",
155
+ source: "nv-sim",
156
+ scenario: resolved.scenario,
157
+ worldThesis: world.thesis,
158
+ agents: resolved.stakeholders.map(s => s.id),
159
+ invariants: world.invariants.map(inv => ({ id: inv.id, description: inv.description })),
160
+ gates: (world.gates ?? []).map(g => ({ id: g.id, label: g.label, severity: g.severity })),
161
+ narrativeEvents: narrativeEvents.map(e => ({ id: e.id, headline: e.headline, round: e.round, severity: e.severity })),
162
+ totalRounds: rounds,
163
+ });
164
+ // Run baseline
165
+ const baselineResult = await (0, swarmSimulation_1.runSwarmSimulation)(resolved.scenario, resolved.stakeholders, resolved.paths, { ...resolved.swarm, rounds });
166
+ for (const round of baselineResult.rounds) {
167
+ broadcast({
168
+ type: "round",
169
+ round: round.round,
170
+ totalRounds: rounds,
171
+ phase: "baseline",
172
+ reactions: round.reactions.map(r => ({
173
+ stakeholder_id: r.stakeholder_id,
174
+ reaction: r.reaction,
175
+ impact: r.impact,
176
+ confidence: r.confidence,
177
+ trigger: r.trigger,
178
+ })),
179
+ avgImpact: round.reactions.reduce((s, r) => s + r.impact, 0) / round.reactions.length,
180
+ maxVolatility: Math.max(...round.reactions.map(r => Math.abs(r.impact))),
181
+ dynamics: round.emergent_dynamics ?? [],
182
+ interventionCount: 0,
183
+ });
184
+ await new Promise(r => setTimeout(r, 400));
185
+ }
186
+ // Run governed with narrative events
187
+ const governedResult = await runGovernedComparison(request, world, resolved.paths, narrativeEvents);
188
+ const nvWorld = (0, worldBridge_1.buildWorldFromScenario)(request, world);
189
+ for (const round of governedResult.governed.swarm.rounds) {
190
+ const reactions = round.reactions.map(r => {
191
+ const verdict = (0, worldBridge_1.evaluateScenarioGuard)({ ...request, scenario: `[R${round.round}] ${r.stakeholder_id}: ${r.reaction}` }, nvWorld, { trace: true, level: "standard" });
192
+ return {
193
+ stakeholder_id: r.stakeholder_id,
194
+ reaction: r.reaction,
195
+ impact: r.impact,
196
+ confidence: r.confidence,
197
+ trigger: r.trigger,
198
+ verdict: { status: verdict.status, reason: verdict.reason, ruleId: verdict.ruleId },
199
+ };
200
+ });
201
+ const interventionCount = (round.emergent_dynamics ?? []).filter(d => d.includes("intervention") || d.includes("governance")).length;
202
+ broadcast({
203
+ type: "round",
204
+ round: round.round,
205
+ totalRounds: rounds,
206
+ phase: "governed",
207
+ reactions,
208
+ avgImpact: round.reactions.reduce((s, r) => s + r.impact, 0) / round.reactions.length,
209
+ maxVolatility: Math.max(...round.reactions.map(r => Math.abs(r.impact))),
210
+ dynamics: round.emergent_dynamics ?? [],
211
+ interventionCount,
212
+ });
213
+ await new Promise(r => setTimeout(r, 500));
214
+ }
215
+ broadcast({ type: "complete", result: governedResult });
216
+ }
217
+ const server = http.createServer(async (req, res) => {
218
+ // CORS preflight
219
+ if (req.method === "OPTIONS") {
220
+ res.writeHead(204, {
221
+ "Access-Control-Allow-Origin": "*",
222
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
223
+ "Access-Control-Allow-Headers": "Content-Type",
224
+ });
225
+ res.end();
226
+ return;
227
+ }
228
+ if (req.url === "/events") {
229
+ res.writeHead(200, {
230
+ "Content-Type": "text/event-stream",
231
+ "Cache-Control": "no-cache",
232
+ "Connection": "keep-alive",
233
+ "Access-Control-Allow-Origin": "*",
234
+ });
235
+ clients.add(res);
236
+ req.on("close", () => clients.delete(res));
237
+ return;
238
+ }
239
+ if (req.url === "/api/worlds" && req.method === "GET") {
240
+ const { resolveWorld } = await Promise.resolve().then(() => __importStar(require("./worldComparison")));
241
+ const worldIds = ["trading", "strait_of_hormuz", "gas_price_spike", "ai_regulation_crisis"];
242
+ const worlds = worldIds.map(id => {
243
+ try {
244
+ const r = resolveWorld(id);
245
+ return {
246
+ id,
247
+ title: r.title,
248
+ thesis: r.world.thesis,
249
+ stateVariables: r.world.state_variables,
250
+ invariants: r.world.invariants,
251
+ gates: r.world.gates ?? [],
252
+ };
253
+ }
254
+ catch {
255
+ return null;
256
+ }
257
+ }).filter(Boolean);
258
+ jsonResponse(res, 200, { worlds });
259
+ return;
260
+ }
261
+ if (req.url === "/api/scenarios" && req.method === "GET") {
262
+ jsonResponse(res, 200, { scenarios: scenarioLibrary_1.SCENARIO_LIBRARY });
263
+ return;
264
+ }
265
+ if (req.url === "/api/narratives" && req.method === "GET") {
266
+ jsonResponse(res, 200, { narratives: narrativeInjection_1.NARRATIVE_PRESETS });
267
+ return;
268
+ }
269
+ if (req.url === "/api/variants" && req.method === "GET") {
270
+ const variants = loadAllVariants();
271
+ jsonResponse(res, 200, { variants });
272
+ return;
273
+ }
274
+ if (req.url === "/api/save-variant" && req.method === "POST") {
275
+ try {
276
+ const body = await readBody(req);
277
+ const payload = JSON.parse(body);
278
+ if (!payload.name || !payload.baseWorld) {
279
+ jsonResponse(res, 400, { error: "name and baseWorld are required" });
280
+ return;
281
+ }
282
+ const id = slugify(payload.name);
283
+ const variant = {
284
+ id,
285
+ name: payload.name,
286
+ description: payload.description ?? "",
287
+ baseWorld: payload.baseWorld,
288
+ stateOverrides: payload.stateOverrides ?? {},
289
+ events: payload.events ?? [],
290
+ rounds: payload.rounds ?? 5,
291
+ createdAt: new Date().toISOString(),
292
+ lastResult: payload.lastResult,
293
+ };
294
+ const filepath = saveVariant(variant);
295
+ jsonResponse(res, 200, { status: "saved", variant, filepath });
296
+ }
297
+ catch (err) {
298
+ jsonResponse(res, 400, { error: "Invalid request body" });
299
+ }
300
+ return;
301
+ }
302
+ if (req.url?.startsWith("/api/delete-variant/") && req.method === "DELETE") {
303
+ const variantId = req.url.split("/api/delete-variant/")[1];
304
+ if (variantId && deleteVariant(variantId)) {
305
+ jsonResponse(res, 200, { status: "deleted", id: variantId });
306
+ }
307
+ else {
308
+ jsonResponse(res, 404, { error: "Variant not found" });
309
+ }
310
+ return;
311
+ }
312
+ if (req.url === "/api/run-sim" && req.method === "POST") {
313
+ if (isRunning) {
314
+ jsonResponse(res, 409, { error: "Simulation already running" });
315
+ return;
316
+ }
317
+ try {
318
+ const body = await readBody(req);
319
+ const config = JSON.parse(body);
320
+ isRunning = true;
321
+ jsonResponse(res, 200, { status: "started", config });
322
+ // Run simulation async, streaming via SSE
323
+ runSimulation(config).catch(err => {
324
+ broadcast({ type: "complete", result: { error: String(err) } });
325
+ }).finally(() => { isRunning = false; });
326
+ }
327
+ catch (err) {
328
+ jsonResponse(res, 400, { error: "Invalid request body" });
329
+ }
330
+ return;
331
+ }
332
+ // ── Governance Evaluate Endpoint ──
333
+ // Universal bridge endpoint: external simulators POST actions here for governance evaluation.
334
+ // Contract: { actor, action, payload, state? } → { decision: ALLOW|BLOCK|MODIFY, reason, modified_action? }
335
+ if (req.url === "/api/evaluate" && req.method === "POST") {
336
+ try {
337
+ const body = await readBody(req);
338
+ const payload = JSON.parse(body);
339
+ if (!payload.actor || !payload.action) {
340
+ jsonResponse(res, 400, { error: "actor and action are required" });
341
+ return;
342
+ }
343
+ // Resolve world for evaluation
344
+ const worldId = payload.world ?? "trading";
345
+ let nvWorld;
346
+ try {
347
+ const { resolveWorld } = await Promise.resolve().then(() => __importStar(require("./worldComparison")));
348
+ const resolved = resolveWorld(worldId);
349
+ const request = {
350
+ scenario: resolved.scenario,
351
+ stakeholders: resolved.stakeholders,
352
+ assumptions: resolved.assumptions,
353
+ constraints: resolved.constraints,
354
+ depth: resolved.depth,
355
+ swarm: resolved.swarm,
356
+ };
357
+ nvWorld = (0, worldBridge_1.buildWorldFromScenario)(request, resolved.world);
358
+ }
359
+ catch {
360
+ // Fallback: build minimal world
361
+ nvWorld = (0, worldBridge_1.buildWorldFromScenario)({
362
+ scenario: `${payload.actor}: ${payload.action}`,
363
+ stakeholders: [],
364
+ });
365
+ }
366
+ // Enable action_space — bridge actions are simulation execution, not thinking-only.
367
+ // Without this, the safety layer flags normal actions like "buy" as execution intents
368
+ // in a thinking-only environment.
369
+ nvWorld.world.players = { ...nvWorld.world.players, action_space: true };
370
+ // Inject simulation-specific guards into the world.
371
+ // The guard engine pattern-matches event.intent against guard.intent_patterns.
372
+ // Without guards, everything defaults to ALLOW. These guards define what
373
+ // simulation actions should be governed.
374
+ if (!nvWorld.guards) {
375
+ nvWorld.guards = {
376
+ guards: [
377
+ {
378
+ id: "sim-panic-actions",
379
+ label: "Block panic-driven actions",
380
+ description: "Prevents panic selling, aggressive shorting, and other destabilizing actions during high volatility",
381
+ category: "structural",
382
+ enforcement: "block",
383
+ immutable: true,
384
+ invariant_ref: nvWorld.invariants[0]?.id,
385
+ intent_patterns: ["panic_sell", "panic sell", "panic buy", "panic_buy"],
386
+ default_enabled: true,
387
+ },
388
+ {
389
+ id: "sim-excessive-leverage",
390
+ label: "Block excessive leverage",
391
+ description: "Prevents increasing leverage positions that could amplify cascades",
392
+ category: "structural",
393
+ enforcement: "block",
394
+ immutable: true,
395
+ intent_patterns: ["increase_leverage", "increase leverage", "max leverage"],
396
+ default_enabled: true,
397
+ },
398
+ {
399
+ id: "sim-aggressive-actions",
400
+ label: "Pause aggressive market actions",
401
+ description: "Requires review for aggressive buying or shorting that could move markets",
402
+ category: "operational",
403
+ enforcement: "pause",
404
+ immutable: false,
405
+ intent_patterns: ["aggressive_buy", "aggressive buy", "aggressive_short", "short"],
406
+ default_enabled: true,
407
+ },
408
+ ],
409
+ intent_vocabulary: {
410
+ "panic_sell": { label: "Panic Sell", pattern: "panic_sell" },
411
+ "increase_leverage": { label: "Increase Leverage", pattern: "increase_leverage" },
412
+ "aggressive_buy": { label: "Aggressive Buy", pattern: "aggressive_buy" },
413
+ "short": { label: "Short Position", pattern: "short" },
414
+ },
415
+ };
416
+ }
417
+ // Build a proper GuardEvent — the guard engine matches intent against intent_patterns.
418
+ // Omit `direction` — setting it enables execution-intent safety checks (prompt injection
419
+ // detection) which falsely flag financial terms like "buy" and "sell". Bridge actions are
420
+ // simulation commands, not user prompts.
421
+ const guardEvent = {
422
+ intent: payload.action,
423
+ tool: "simulation",
424
+ scope: `bridge/${payload.actor}`,
425
+ actionCategory: "execute",
426
+ riskLevel: (["panic_sell", "panic_buy", "increase_leverage"].includes(payload.action) ? "high" : "medium"),
427
+ args: {
428
+ actor: payload.actor,
429
+ action: payload.action,
430
+ ...(payload.payload ?? {}),
431
+ },
432
+ };
433
+ // Evaluate directly via the governance module
434
+ let verdict;
435
+ try {
436
+ const nv = await Promise.resolve().then(() => __importStar(require("@neuroverseos/governance")));
437
+ verdict = nv.evaluateGuard(guardEvent, nvWorld, {
438
+ trace: true,
439
+ level: "standard",
440
+ });
441
+ }
442
+ catch {
443
+ // Fallback to scenario guard evaluation
444
+ verdict = (0, worldBridge_1.evaluateScenarioGuard)({
445
+ scenario: `[BRIDGE] ${payload.actor}: ${payload.action}`,
446
+ stakeholders: [{ id: payload.actor, description: payload.actor, disposition: "neutral", priorities: [] }],
447
+ }, nvWorld, { trace: true, level: "standard" });
448
+ }
449
+ // Map verdict to bridge protocol
450
+ const decision = verdict.status === "BLOCK" ? "BLOCK"
451
+ : verdict.status === "PAUSE" ? "MODIFY"
452
+ : "ALLOW";
453
+ // Broadcast governance event to connected SSE clients
454
+ broadcast({
455
+ type: "round",
456
+ round: 0,
457
+ totalRounds: 0,
458
+ phase: "governed",
459
+ reactions: [{
460
+ stakeholder_id: payload.actor,
461
+ reaction: payload.action,
462
+ impact: 0,
463
+ confidence: 0.5,
464
+ trigger: "bridge",
465
+ verdict: { status: verdict.status, reason: verdict.reason, ruleId: verdict.ruleId },
466
+ }],
467
+ avgImpact: 0,
468
+ maxVolatility: 0,
469
+ dynamics: [],
470
+ interventionCount: decision !== "ALLOW" ? 1 : 0,
471
+ });
472
+ jsonResponse(res, 200, {
473
+ decision,
474
+ reason: verdict.reason ?? null,
475
+ rule_id: verdict.ruleId ?? null,
476
+ evidence: verdict.evidence ?? null,
477
+ modified_action: decision === "MODIFY" ? payload.payload : null,
478
+ });
479
+ }
480
+ catch (err) {
481
+ // Fail open — return ALLOW on any error
482
+ jsonResponse(res, 200, {
483
+ decision: "ALLOW",
484
+ reason: "Governance evaluation error — fail open",
485
+ rule_id: null,
486
+ evidence: null,
487
+ modified_action: null,
488
+ });
489
+ }
490
+ return;
491
+ }
492
+ // List available live adapters
493
+ if (req.url === "/api/adapters" && req.method === "GET") {
494
+ const adapters = Object.values(liveAdapter_1.ADAPTER_REGISTRY).map(a => ({
495
+ id: a.id,
496
+ label: a.label,
497
+ description: a.description,
498
+ }));
499
+ jsonResponse(res, 200, { adapters });
500
+ return;
501
+ }
502
+ // Run simulation via live adapter (external process)
503
+ if (req.url === "/api/run-live" && req.method === "POST") {
504
+ if (isRunning) {
505
+ jsonResponse(res, 409, { error: "Simulation already running" });
506
+ return;
507
+ }
508
+ try {
509
+ const body = await readBody(req);
510
+ const payload = JSON.parse(body);
511
+ const adapter = (0, liveAdapter_1.createAdapter)(payload.adapterId, payload.options);
512
+ if (!adapter) {
513
+ jsonResponse(res, 400, { error: `Unknown adapter: ${payload.adapterId}` });
514
+ return;
515
+ }
516
+ isRunning = true;
517
+ jsonResponse(res, 200, { status: "started", adapter: payload.adapterId });
518
+ // Resolve world for governance evaluation
519
+ const { resolveWorld } = await Promise.resolve().then(() => __importStar(require("./worldComparison")));
520
+ const resolved = resolveWorld(payload.worldId ?? "trading");
521
+ const world = { ...resolved.world };
522
+ if (payload.stateOverrides) {
523
+ const updatedVars = world.state_variables.map(sv => {
524
+ if (payload.stateOverrides && sv.id in payload.stateOverrides) {
525
+ return { ...sv, default_value: payload.stateOverrides[sv.id] };
526
+ }
527
+ return sv;
528
+ });
529
+ world.state_variables = updatedVars;
530
+ }
531
+ const request = {
532
+ scenario: resolved.scenario,
533
+ stakeholders: resolved.stakeholders,
534
+ assumptions: resolved.assumptions,
535
+ constraints: resolved.constraints,
536
+ depth: resolved.depth,
537
+ swarm: resolved.swarm,
538
+ };
539
+ const nvWorld = (0, worldBridge_1.buildWorldFromScenario)(request, world);
540
+ // Send meta event with adapter source
541
+ broadcast({
542
+ type: "meta",
543
+ source: payload.adapterId,
544
+ scenario: resolved.scenario,
545
+ worldThesis: world.thesis,
546
+ agents: [], // will be populated dynamically from adapter
547
+ invariants: world.invariants.map(inv => ({ id: inv.id, description: inv.description })),
548
+ gates: (world.gates ?? []).map(g => ({ id: g.id, label: g.label, severity: g.severity })),
549
+ narrativeEvents: [],
550
+ totalRounds: 0,
551
+ });
552
+ // Listen for rounds from the adapter
553
+ adapter.on("round", (liveRound) => {
554
+ // Build lookup of original verdicts from adapter's adaptation data.
555
+ // When an external bridge (e.g. MiroFish) already applied governance,
556
+ // the verdict lives in adaptation.deltas — use it instead of re-evaluating
557
+ // (re-evaluating the MODIFIED action would return ALLOW, hiding the BLOCK).
558
+ const adaptationByAgent = new Map();
559
+ if (liveRound.adaptation?.deltas) {
560
+ for (const delta of liveRound.adaptation.deltas) {
561
+ adaptationByAgent.set(delta.agent, {
562
+ status: delta.decision,
563
+ reason: delta.reason,
564
+ ruleId: delta.rule,
565
+ });
566
+ }
567
+ }
568
+ const reactions = liveRound.agentActions.map(a => {
569
+ // Priority: per-action verdict > adaptation delta > local re-evaluation
570
+ const actionVerdict = a.verdict ? { status: a.verdict.status, reason: a.verdict.reason, ruleId: a.verdict.rule } : null;
571
+ const bridgeVerdict = actionVerdict ?? adaptationByAgent.get(a.agent);
572
+ let verdict;
573
+ if (bridgeVerdict) {
574
+ verdict = bridgeVerdict;
575
+ }
576
+ else {
577
+ const guard = (0, worldBridge_1.evaluateScenarioGuard)({ ...request, scenario: `[R${liveRound.round}] ${a.agent}: ${a.action}` }, nvWorld, { trace: true, level: "standard" });
578
+ verdict = { status: guard.status, reason: guard.reason, ruleId: guard.ruleId };
579
+ }
580
+ return {
581
+ stakeholder_id: a.agent,
582
+ reaction: a.action,
583
+ impact: a.impact,
584
+ confidence: a.confidence ?? 0.5,
585
+ trigger: liveRound.source,
586
+ verdict,
587
+ };
588
+ });
589
+ const interventionCount = reactions.filter(r => r.verdict.status !== "ALLOW").length;
590
+ broadcast({
591
+ type: "round",
592
+ round: liveRound.round,
593
+ totalRounds: 0, // unknown for live streams
594
+ phase: "governed",
595
+ reactions,
596
+ avgImpact: reactions.reduce((s, r) => s + r.impact, 0) / (reactions.length || 1),
597
+ maxVolatility: Math.max(0, ...reactions.map(r => Math.abs(r.impact))),
598
+ dynamics: liveRound.emergentDynamics ?? [],
599
+ interventionCount,
600
+ });
601
+ });
602
+ adapter.on("complete", () => {
603
+ isRunning = false;
604
+ });
605
+ adapter.on("error", (err) => {
606
+ broadcast({ type: "complete", result: { error: String(err) } });
607
+ isRunning = false;
608
+ });
609
+ adapter.start().catch(err => {
610
+ broadcast({ type: "complete", result: { error: String(err) } });
611
+ isRunning = false;
612
+ });
613
+ }
614
+ catch (err) {
615
+ jsonResponse(res, 400, { error: "Invalid request body" });
616
+ }
617
+ return;
618
+ }
619
+ // Serve the interactive dashboard
620
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
621
+ res.end(INTERACTIVE_DASHBOARD_HTML);
622
+ });
623
+ server.listen(port, () => {
624
+ onReady(`http://localhost:${port}`);
625
+ });
626
+ return { server };
627
+ }
628
+ const INTERACTIVE_DASHBOARD_HTML = `<!DOCTYPE html>
629
+ <html lang="en">
630
+ <head>
631
+ <meta charset="UTF-8">
632
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
633
+ <title>NV-SIM — Scenario Control Platform</title>
634
+ <style>
635
+ * { margin: 0; padding: 0; box-sizing: border-box; }
636
+ body { font-family: 'SF Mono', 'Fira Code', monospace; background: #0a0a0a; color: #e0e0e0; overflow: hidden; }
637
+ .header { padding: 12px 20px; border-bottom: 1px solid #1a1a1a; display: flex; justify-content: space-between; align-items: center; }
638
+ .header h1 { font-size: 15px; color: #fff; }
639
+ .header .sub { font-size: 11px; color: #555; margin-left: 12px; }
640
+ .status { font-size: 11px; padding: 3px 10px; border-radius: 10px; }
641
+ .status.idle { background: #1a1a2e; color: #818cf8; }
642
+ .status.live { background: #052e16; color: #4ade80; animation: pulse 2s infinite; }
643
+ .status.complete { background: #1e1b4b; color: #818cf8; }
644
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
645
+
646
+ .layout { display: grid; grid-template-columns: 340px 1fr; height: calc(100vh - 49px); }
647
+
648
+ /* LEFT PANEL — Controls */
649
+ .controls { background: #0d0d0d; border-right: 1px solid #1a1a1a; overflow-y: auto; padding: 16px; }
650
+ .ctrl-section { margin-bottom: 20px; }
651
+ .ctrl-section h3 { font-size: 11px; color: #555; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid #1a1a1a; }
652
+
653
+ .ctrl-row { margin-bottom: 12px; }
654
+ .ctrl-label { font-size: 11px; color: #888; margin-bottom: 4px; display: flex; justify-content: space-between; }
655
+ .ctrl-label .val { color: #ccc; font-weight: 600; }
656
+
657
+ input[type="range"] { width: 100%; height: 4px; -webkit-appearance: none; background: #222; border-radius: 2px; outline: none; }
658
+ input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: #818cf8; cursor: pointer; }
659
+ input[type="range"]::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; background: #818cf8; cursor: pointer; border: none; }
660
+
661
+ select { width: 100%; background: #111; color: #ccc; border: 1px solid #333; padding: 6px 8px; border-radius: 4px; font-family: inherit; font-size: 12px; }
662
+
663
+ .toggle-row { display: flex; align-items: center; gap: 8px; }
664
+ .toggle { position: relative; width: 36px; height: 20px; background: #333; border-radius: 10px; cursor: pointer; transition: background 0.2s; }
665
+ .toggle.on { background: #4ade80; }
666
+ .toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; background: #fff; border-radius: 50%; transition: transform 0.2s; }
667
+ .toggle.on::after { transform: translateX(16px); }
668
+ .toggle-label { font-size: 12px; color: #999; }
669
+
670
+ .inject-row { display: flex; gap: 6px; margin-bottom: 6px; }
671
+ .inject-row select { flex: 1; }
672
+ .inject-row input { width: 50px; background: #111; color: #ccc; border: 1px solid #333; padding: 4px 6px; border-radius: 4px; font-family: inherit; font-size: 12px; text-align: center; }
673
+ .inject-list { margin-bottom: 8px; }
674
+ .inject-item { font-size: 11px; color: #888; padding: 3px 6px; background: #111; border-radius: 3px; margin-bottom: 3px; display: flex; justify-content: space-between; }
675
+ .inject-item .remove { color: #f87171; cursor: pointer; }
676
+
677
+ .btn { width: 100%; padding: 10px; border: none; border-radius: 6px; font-family: inherit; font-size: 13px; font-weight: 700; cursor: pointer; text-transform: uppercase; letter-spacing: 1px; transition: all 0.2s; }
678
+ .btn-run { background: #4ade80; color: #0a0a0a; }
679
+ .btn-run:hover { background: #22c55e; }
680
+ .btn-run:disabled { background: #1a3a1a; color: #555; cursor: not-allowed; }
681
+ .btn-add { background: #222; color: #818cf8; padding: 6px; font-size: 11px; }
682
+ .btn-add:hover { background: #2a2a3a; }
683
+
684
+ .scenario-btn { display: block; width: 100%; padding: 8px 10px; margin-bottom: 4px; background: #111; border: 1px solid #222; border-radius: 4px; color: #ccc; font-family: inherit; font-size: 11px; text-align: left; cursor: pointer; }
685
+ .scenario-btn:hover { border-color: #818cf8; background: #151520; }
686
+ .scenario-btn .stitle { font-weight: 600; color: #e0e0e0; }
687
+ .scenario-btn .sdesc { color: #666; font-size: 10px; margin-top: 2px; }
688
+
689
+ /* Save variant */
690
+ .btn-save { background: #1a1a2e; color: #818cf8; margin-top: 0; }
691
+ .btn-save:hover { background: #252540; }
692
+ .btn-confirm { flex: 1; background: #4ade80; color: #0a0a0a; padding: 7px; font-size: 11px; }
693
+ .btn-cancel { flex: 1; background: #222; color: #888; padding: 7px; font-size: 11px; }
694
+ .save-input { width: 100%; background: #111; color: #ccc; border: 1px solid #333; padding: 7px 8px; border-radius: 4px; font-family: inherit; font-size: 12px; }
695
+ .save-input:focus { border-color: #818cf8; outline: none; }
696
+
697
+ /* Variant cards */
698
+ .variant-card { background: #111; border: 1px solid #222; border-radius: 4px; padding: 8px 10px; margin-bottom: 4px; cursor: pointer; position: relative; }
699
+ .variant-card:hover { border-color: #4ade80; background: #0f1f0f; }
700
+ .variant-card .vname { font-size: 12px; font-weight: 600; color: #e0e0e0; }
701
+ .variant-card .vdesc { font-size: 10px; color: #666; margin-top: 2px; }
702
+ .variant-card .vmeta { font-size: 10px; color: #444; margin-top: 4px; }
703
+ .variant-card .vmeta .vresult { color: #4ade80; }
704
+ .variant-card .vdelete { position: absolute; top: 6px; right: 8px; color: #f87171; font-size: 10px; cursor: pointer; opacity: 0; transition: opacity 0.2s; }
705
+ .variant-card:hover .vdelete { opacity: 1; }
706
+ .variant-card .vbase { display: inline-block; font-size: 9px; padding: 1px 5px; background: #1a1a2e; color: #818cf8; border-radius: 3px; margin-top: 3px; }
707
+
708
+ /* RIGHT PANEL — Simulation viewer */
709
+ .viewer { display: grid; grid-template-rows: auto 1fr auto; overflow: hidden; }
710
+ .viewer-top { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: #1a1a1a; }
711
+ .viewer-mid { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: #1a1a1a; overflow: hidden; }
712
+ .viewer-bottom { background: #0a0a0a; border-top: 1px solid #1a1a1a; padding: 12px 16px; max-height: 180px; overflow-y: auto; }
713
+
714
+ .vpanel { background: #0a0a0a; padding: 14px; overflow-y: auto; }
715
+ .vpanel h2 { font-size: 11px; color: #555; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 10px; }
716
+
717
+ /* Metrics */
718
+ .metric-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
719
+ .metric-box { background: #111; border: 1px solid #222; border-radius: 6px; padding: 10px; text-align: center; }
720
+ .metric-box .value { font-size: 20px; font-weight: 700; color: #fff; }
721
+ .metric-box .label { font-size: 10px; color: #555; margin-top: 2px; }
722
+ .metric-box.good .value { color: #4ade80; }
723
+ .metric-box.bad .value { color: #ef4444; }
724
+ .metric-box.warn .value { color: #fbbf24; }
725
+
726
+ /* Agent bars */
727
+ .agent-row { display: flex; align-items: center; gap: 6px; padding: 3px 0; font-size: 11px; }
728
+ .agent-name { width: 130px; color: #777; flex-shrink: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
729
+ .impact-bar-bg { flex: 1; height: 14px; background: #1a1a1a; border-radius: 3px; position: relative; overflow: hidden; }
730
+ .impact-bar { height: 100%; border-radius: 3px; transition: width 0.4s ease; position: absolute; top: 0; }
731
+ .impact-bar.positive { background: #4ade80; right: 50%; }
732
+ .impact-bar.negative { background: #ef4444; left: 50%; }
733
+ .impact-val { width: 44px; text-align: right; color: #999; font-size: 10px; }
734
+ .center-line { position: absolute; left: 50%; top: 0; bottom: 0; width: 1px; background: #333; }
735
+ .verdict { display: inline-block; padding: 1px 5px; border-radius: 3px; font-size: 9px; font-weight: 600; margin-left: 4px; }
736
+ .verdict.ALLOW { background: #052e16; color: #4ade80; }
737
+ .verdict.BLOCK { background: #2d0606; color: #f87171; }
738
+ .verdict.PAUSE { background: #2d2006; color: #fbbf24; }
739
+
740
+ /* Chart */
741
+ .chart-container { position: relative; height: 100%; min-height: 150px; }
742
+ canvas { width: 100% !important; height: 100% !important; }
743
+
744
+ /* Simulation Trace */
745
+ .trace-round { margin-bottom: 10px; border: 1px solid #1a1a1a; border-radius: 4px; overflow: hidden; }
746
+ .trace-round-header { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: #111; cursor: pointer; user-select: none; }
747
+ .trace-round-header:hover { background: #181818; }
748
+ .trace-phase { font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 3px; text-transform: uppercase; letter-spacing: 0.5px; }
749
+ .trace-phase.baseline { background: #1e293b; color: #60a5fa; }
750
+ .trace-phase.governed { background: #052e16; color: #4ade80; }
751
+ .trace-round-label { font-size: 11px; color: #ccc; font-weight: 600; }
752
+ .trace-round-metrics { margin-left: auto; font-size: 10px; color: #666; display: flex; gap: 10px; }
753
+ .trace-body { padding: 0 10px 8px; }
754
+ .trace-body[data-collapsed="true"] { display: none; }
755
+ .trace-section { margin-top: 6px; }
756
+ .trace-section-label { font-size: 9px; color: #555; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 3px; display: flex; align-items: center; gap: 5px; }
757
+ .trace-section-label::before { content: ''; display: inline-block; width: 6px; height: 6px; border-radius: 50%; }
758
+ .trace-section-label.event::before { background: #f59e0b; }
759
+ .trace-section-label.agents::before { background: #3b82f6; }
760
+ .trace-section-label.governance::before { background: #10b981; }
761
+ .trace-event-item { font-size: 10px; color: #e2e8f0; padding: 3px 0 3px 11px; border-left: 2px solid #f59e0b; margin-left: 2px; }
762
+ .trace-event-severity { font-size: 9px; font-weight: 600; padding: 0 4px; border-radius: 2px; margin-left: 4px; }
763
+ .trace-event-severity.major, .trace-event-severity.extreme { background: #7f1d1d; color: #fca5a5; }
764
+ .trace-event-severity.moderate { background: #713f12; color: #fde68a; }
765
+ .trace-event-severity.minor { background: #1e3a2f; color: #86efac; }
766
+ .trace-agent-item { font-size: 10px; padding: 2px 0 2px 11px; border-left: 2px solid #3b82f6; margin-left: 2px; display: flex; align-items: center; gap: 6px; }
767
+ .trace-agent-name { color: #93c5fd; font-weight: 500; min-width: 80px; }
768
+ .trace-agent-action { color: #a1a1aa; flex: 1; }
769
+ .trace-agent-impact { font-size: 9px; font-weight: 600; min-width: 36px; text-align: right; }
770
+ .trace-agent-impact.positive { color: #4ade80; }
771
+ .trace-agent-impact.negative { color: #f87171; }
772
+ .trace-gov-item { font-size: 10px; padding: 3px 6px 3px 11px; border-left: 2px solid #10b981; margin-left: 2px; display: flex; align-items: center; gap: 6px; }
773
+ .trace-gov-rule { font-size: 9px; color: #999; font-family: monospace; }
774
+ .trace-gov-reason { color: #d1d5db; flex: 1; }
775
+ .trace-dynamics { font-size: 10px; color: #a78bfa; padding: 2px 0 2px 11px; border-left: 2px solid #7c3aed; margin-left: 2px; font-style: italic; }
776
+ .trace-arrow { color: #333; font-size: 10px; text-align: center; padding: 2px 0; }
777
+ .trace-empty { font-size: 10px; color: #444; font-style: italic; padding: 4px 0; }
778
+
779
+ /* World info */
780
+ .world-thesis { font-size: 11px; color: #ccc; font-style: italic; margin-bottom: 8px; }
781
+ .inv-item { font-size: 10px; color: #777; padding: 2px 0; }
782
+
783
+ /* Empty state */
784
+ .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: #333; }
785
+ .empty-state .icon { font-size: 48px; margin-bottom: 12px; }
786
+ .empty-state .msg { font-size: 13px; }
787
+ .empty-state .hint { font-size: 11px; color: #2a2a2a; margin-top: 6px; }
788
+
789
+ /* System Shift Card */
790
+ .system-shift { display: none; margin: 12px 16px; border: 1px solid #1a2e1a; border-radius: 8px; background: #0a0f0a; overflow: hidden; animation: fadeInUp 0.4s ease; }
791
+ .system-shift.visible { display: block; }
792
+ @keyframes fadeInUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
793
+ .ss-header { display: flex; align-items: center; gap: 8px; padding: 12px 14px; background: #0d150d; border-bottom: 1px solid #1a2e1a; }
794
+ .ss-icon { width: 8px; height: 8px; border-radius: 50%; background: #4ade80; box-shadow: 0 0 6px #4ade8066; }
795
+ .ss-title { font-size: 11px; font-weight: 700; color: #4ade80; text-transform: uppercase; letter-spacing: 1.5px; }
796
+ .ss-rule { font-size: 13px; font-weight: 600; color: #e0e0e0; padding: 10px 14px 0; }
797
+ .ss-body { padding: 10px 14px 14px; display: grid; gap: 8px; }
798
+ .ss-section { background: #111; border: 1px solid #1a1a1a; border-radius: 6px; padding: 10px 12px; }
799
+ .ss-section-label { font-size: 9px; font-weight: 700; color: #555; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 6px; }
800
+ .ss-adapt-rate { font-size: 20px; font-weight: 700; color: #4ade80; }
801
+ .ss-adapt-desc { font-size: 11px; color: #888; margin-top: 2px; }
802
+ .ss-shift-item { font-size: 11px; color: #999; padding: 2px 0; display: flex; align-items: center; gap: 6px; }
803
+ .ss-shift-arrow { color: #4ade80; font-weight: 600; }
804
+ .ss-pattern-tag { display: inline-block; font-size: 10px; padding: 2px 8px; background: #1a1a2e; color: #818cf8; border-radius: 3px; margin-right: 4px; margin-bottom: 4px; }
805
+ .ss-impact-row { display: flex; justify-content: space-between; align-items: center; font-size: 11px; color: #999; padding: 2px 0; }
806
+ .ss-impact-delta { color: #4ade80; font-weight: 600; }
807
+ .ss-impact-delta.negative { color: #f87171; }
808
+ .ss-narrative { font-size: 12px; color: #ccc; line-height: 1.5; font-style: italic; border-left: 2px solid #4ade80; padding-left: 10px; }
809
+ .ss-scale { font-size: 10px; color: #555; padding: 0 14px 6px; }
810
+ .ss-scale strong { color: #888; }
811
+ .ss-flow { display: flex; align-items: center; gap: 6px; padding: 6px 14px; font-size: 10px; color: #444; }
812
+ .ss-flow-arrow { color: #4ade80; }
813
+ .ss-raw-toggle { display: flex; align-items: center; gap: 6px; padding: 8px 12px; background: #0d0d0d; border: none; border-top: 1px solid #1a1a1a; color: #555; font-family: inherit; font-size: 10px; cursor: pointer; width: 100%; text-align: left; transition: color 0.2s; }
814
+ .ss-raw-toggle:hover { color: #888; }
815
+ .ss-raw-toggle .arrow { transition: transform 0.2s; }
816
+ .ss-raw-toggle.open .arrow { transform: rotate(90deg); }
817
+ .ss-raw-detail { max-height: 0; overflow: hidden; transition: max-height 0.3s ease; }
818
+ .ss-raw-detail.open { max-height: 200px; overflow-y: auto; }
819
+ .ss-raw-list { padding: 6px 12px; }
820
+ .ss-raw-item { font-size: 10px; color: #666; padding: 2px 0; display: flex; gap: 6px; }
821
+ .ss-raw-item .raw-agent { color: #888; min-width: 100px; }
822
+ .ss-raw-item .raw-action { color: #999; flex: 1; }
823
+ .ss-raw-item .raw-verdict { font-size: 9px; font-weight: 600; padding: 0 4px; border-radius: 2px; }
824
+ .ss-raw-item .raw-verdict.BLOCK { background: #2d0606; color: #f87171; }
825
+ .ss-raw-item .raw-verdict.MODIFY { background: #2d2006; color: #fbbf24; }
826
+
827
+ /* Integration Quick-Start (in controls panel) */
828
+ .integrate-section { background: #0d0d15; border: 1px solid #1a1a2e; border-radius: 6px; padding: 10px 12px; margin-top: 8px; }
829
+ .integrate-section h4 { font-size: 10px; color: #818cf8; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
830
+ .integrate-code { font-size: 10px; color: #ccc; background: #111; border: 1px solid #222; border-radius: 4px; padding: 8px; overflow-x: auto; white-space: pre; line-height: 1.5; }
831
+ .integrate-code .kw { color: #818cf8; }
832
+ .integrate-code .str { color: #4ade80; }
833
+ .integrate-code .comment { color: #555; }
834
+ .integrate-endpoint { font-size: 11px; color: #888; margin-top: 6px; }
835
+ .integrate-endpoint code { color: #4ade80; background: #111; padding: 1px 5px; border-radius: 3px; }
836
+ </style>
837
+ </head>
838
+ <body>
839
+ <div class="header">
840
+ <div style="display:flex;align-items:center">
841
+ <h1>NV-SIM</h1>
842
+ <span class="sub">Scenario Control Platform</span>
843
+ </div>
844
+ <span id="status" class="status idle">Ready</span>
845
+ </div>
846
+
847
+ <div class="layout">
848
+ <!-- LEFT: CONTROLS -->
849
+ <div class="controls" id="controls-panel">
850
+ <!-- Simulation Engine selector -->
851
+ <div class="ctrl-section">
852
+ <h3>Simulation Engine</h3>
853
+ <div class="ctrl-row">
854
+ <select id="engine-select">
855
+ <option value="nv-sim" selected>NV-SIM (Built-in)</option>
856
+ </select>
857
+ </div>
858
+ <div id="engine-status" style="font-size:10px;color:#555;margin-top:4px"></div>
859
+ </div>
860
+
861
+ <!-- World selector -->
862
+ <div class="ctrl-section">
863
+ <h3>World</h3>
864
+ <div class="ctrl-row">
865
+ <select id="world-select"></select>
866
+ </div>
867
+ <div id="world-thesis" class="world-thesis"></div>
868
+ </div>
869
+
870
+ <!-- State variables (dynamic sliders) -->
871
+ <div class="ctrl-section" id="state-vars-section" style="display:none">
872
+ <h3>World Rules</h3>
873
+ <div id="state-vars"></div>
874
+ </div>
875
+
876
+ <!-- Scenario presets -->
877
+ <div class="ctrl-section">
878
+ <h3>Scenario Presets</h3>
879
+ <div id="scenario-list"></div>
880
+ </div>
881
+
882
+ <!-- Narrative injection -->
883
+ <div class="ctrl-section">
884
+ <h3>Narrative Events</h3>
885
+ <div class="inject-row">
886
+ <select id="event-select"></select>
887
+ <input type="number" id="event-round" min="1" max="20" value="3" placeholder="R">
888
+ </div>
889
+ <button class="btn btn-add" id="add-event-btn">+ Add Event</button>
890
+ <div id="inject-list" class="inject-list" style="margin-top:8px"></div>
891
+ </div>
892
+
893
+ <!-- Rounds -->
894
+ <div class="ctrl-section">
895
+ <h3>Simulation</h3>
896
+ <div class="ctrl-row">
897
+ <div class="ctrl-label">
898
+ <span>Rounds</span>
899
+ <span class="val" id="rounds-val">5</span>
900
+ </div>
901
+ <input type="range" id="rounds-slider" min="3" max="12" value="5">
902
+ </div>
903
+ </div>
904
+
905
+ <!-- Run button -->
906
+ <button class="btn btn-run" id="run-btn">Run Simulation</button>
907
+
908
+ <!-- Save as variant -->
909
+ <div id="save-section" style="margin-top:12px">
910
+ <button class="btn btn-save" id="save-btn">Save as World Variant</button>
911
+ <div id="save-form" style="display:none;margin-top:8px">
912
+ <input type="text" id="variant-name" placeholder="Variant name (e.g. Hormuz Closed + 3x Leverage)" class="save-input">
913
+ <input type="text" id="variant-desc" placeholder="What does this variant test?" class="save-input" style="margin-top:4px">
914
+ <div style="display:flex;gap:6px;margin-top:6px">
915
+ <button class="btn btn-confirm" id="confirm-save-btn">Save</button>
916
+ <button class="btn btn-cancel" id="cancel-save-btn">Cancel</button>
917
+ </div>
918
+ </div>
919
+ </div>
920
+
921
+ <!-- Saved variants -->
922
+ <div class="ctrl-section" style="margin-top:16px">
923
+ <h3>Saved Variants</h3>
924
+ <div id="variant-list"><div style="font-size:11px;color:#333">No saved variants yet</div></div>
925
+ </div>
926
+
927
+ <!-- Integration Quick-Start -->
928
+ <div class="ctrl-section" style="margin-top:16px">
929
+ <h3>Integrate Your Simulator</h3>
930
+ <div class="integrate-section">
931
+ <h4>Connect in 3 lines</h4>
932
+ <div class="integrate-code"><span class="kw">from</span> neuroverse_bridge <span class="kw">import</span> evaluate
933
+
934
+ verdict = evaluate(
935
+ actor=<span class="str">"agent_1"</span>,
936
+ action=<span class="str">"panic_sell"</span>,
937
+ world=<span class="str">"trading"</span>
938
+ )
939
+
940
+ <span class="kw">if</span> verdict[<span class="str">"decision"</span>] == <span class="str">"BLOCK"</span>:
941
+ action = <span class="str">"hold"</span> <span class="comment"># adapted</span></div>
942
+ <div class="integrate-endpoint">
943
+ Endpoint: <code id="integrate-url">POST /api/evaluate</code>
944
+ </div>
945
+ <div style="font-size:10px;color:#444;margin-top:6px">
946
+ Fail-open · 500ms timeout · Stateless
947
+ </div>
948
+ <div style="margin-top:8px;font-size:10px">
949
+ <span style="display:inline-block;padding:2px 6px;background:#2d0606;color:#f87171;border-radius:3px;margin-right:3px">BLOCK</span> replaced
950
+ <span style="display:inline-block;padding:2px 6px;background:#2d2006;color:#fbbf24;border-radius:3px;margin-right:3px;margin-left:4px">MODIFY</span> constrained
951
+ <span style="display:inline-block;padding:2px 6px;background:#052e16;color:#4ade80;border-radius:3px;margin-left:4px">ALLOW</span> proceeds
952
+ </div>
953
+ </div>
954
+ </div>
955
+ </div>
956
+
957
+ <!-- RIGHT: VIEWER -->
958
+ <div class="viewer">
959
+ <div class="viewer-top">
960
+ <div class="vpanel">
961
+ <h2>Live Metrics</h2>
962
+ <div class="metric-grid">
963
+ <div class="metric-box"><div class="value" id="m-stability">--</div><div class="label">Stability</div></div>
964
+ <div class="metric-box"><div class="value" id="m-volatility">--</div><div class="label">Volatility</div></div>
965
+ <div class="metric-box"><div class="value" id="m-round">--</div><div class="label">Round</div></div>
966
+ <div class="metric-box"><div class="value" id="m-interventions">0</div><div class="label">Interventions</div></div>
967
+ </div>
968
+ </div>
969
+ <div class="vpanel">
970
+ <h2>World Rules Active</h2>
971
+ <div id="active-invariants"></div>
972
+ </div>
973
+ </div>
974
+
975
+ <div class="viewer-mid">
976
+ <div class="vpanel" id="agents-panel">
977
+ <h2>Agent Impacts</h2>
978
+ <div id="agents">
979
+ <div class="empty-state"><div class="icon">&gt;_</div><div class="msg">Configure world and run simulation</div><div class="hint">Adjust rules on the left, then press Run</div></div>
980
+ </div>
981
+ </div>
982
+ <div class="vpanel">
983
+ <h2>Impact Timeline</h2>
984
+ <div class="chart-container"><canvas id="chart"></canvas></div>
985
+ </div>
986
+ </div>
987
+
988
+ <!-- System Shift Card — the demo moment -->
989
+ <div id="system-shift" class="system-shift">
990
+ <div class="ss-header">
991
+ <div class="ss-icon"></div>
992
+ <span class="ss-title">System Shift</span>
993
+ </div>
994
+ <div class="ss-rule" id="ss-rule"></div>
995
+ <div class="ss-scale" id="ss-scale"></div>
996
+ <div class="ss-flow">
997
+ <span>Rule</span><span class="ss-flow-arrow">→</span>
998
+ <span>Behavioral Shift</span><span class="ss-flow-arrow">→</span>
999
+ <span>Emergent Pattern</span><span class="ss-flow-arrow">→</span>
1000
+ <span>System Outcome</span>
1001
+ </div>
1002
+ <div class="ss-body">
1003
+ <div class="ss-section">
1004
+ <div class="ss-section-label">Behavioral Shift</div>
1005
+ <div class="ss-adapt-rate" id="ss-adapt-rate"></div>
1006
+ <div class="ss-adapt-desc" id="ss-adapt-desc"></div>
1007
+ <div id="ss-shifts"></div>
1008
+ </div>
1009
+ <div class="ss-section">
1010
+ <div class="ss-section-label">What Emerged</div>
1011
+ <div id="ss-patterns"></div>
1012
+ </div>
1013
+ <div class="ss-section">
1014
+ <div class="ss-section-label">System Outcome</div>
1015
+ <div id="ss-impacts"></div>
1016
+ </div>
1017
+ <div class="ss-section">
1018
+ <div class="ss-section-label">What Actually Happened</div>
1019
+ <div class="ss-narrative" id="ss-narrative"></div>
1020
+ </div>
1021
+ </div>
1022
+ <button class="ss-raw-toggle" id="ss-raw-toggle">
1023
+ <span class="arrow">▶</span> View raw detail
1024
+ </button>
1025
+ <div class="ss-raw-detail" id="ss-raw-detail">
1026
+ <div class="ss-raw-list" id="ss-raw-list"></div>
1027
+ </div>
1028
+ </div>
1029
+
1030
+ <div class="viewer-bottom">
1031
+ <h2 style="font-size:11px;color:#555;text-transform:uppercase;letter-spacing:1px;margin-bottom:8px">Simulation Trace <span id="trace-source" style="color:#4ade80;font-weight:600;margin-left:6px"></span> <span style="color:#333;font-weight:400">— Events → Agents → Rules → Outcomes</span></h2>
1032
+ <div id="log"></div>
1033
+ </div>
1034
+ </div>
1035
+ </div>
1036
+
1037
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"><\/script>
1038
+ <script>
1039
+ // ============================================
1040
+ // STATE
1041
+ // ============================================
1042
+ let worlds = [];
1043
+ let scenarios = {};
1044
+ let narratives = {};
1045
+ let currentWorld = null;
1046
+ let injectedEvents = [];
1047
+ let totalInterventions = 0;
1048
+ let baselineImpacts = [];
1049
+ let governedImpacts = [];
1050
+ let chartLabels = [];
1051
+ let chart = null;
1052
+ let narrativeEventsByRound = {}; // { round: [{ id, headline, severity }] }
1053
+
1054
+ const statusEl = document.getElementById('status');
1055
+ const worldSelect = document.getElementById('world-select');
1056
+ const stateVarsSection = document.getElementById('state-vars-section');
1057
+ const stateVarsEl = document.getElementById('state-vars');
1058
+ const scenarioListEl = document.getElementById('scenario-list');
1059
+ const eventSelect = document.getElementById('event-select');
1060
+ const eventRoundInput = document.getElementById('event-round');
1061
+ const injectListEl = document.getElementById('inject-list');
1062
+ const roundsSlider = document.getElementById('rounds-slider');
1063
+ const roundsVal = document.getElementById('rounds-val');
1064
+ const runBtn = document.getElementById('run-btn');
1065
+ const agentsEl = document.getElementById('agents');
1066
+ const logEl = document.getElementById('log');
1067
+ const activeInvEl = document.getElementById('active-invariants');
1068
+ const engineSelect = document.getElementById('engine-select');
1069
+ const engineStatusEl = document.getElementById('engine-status');
1070
+ const traceSourceEl = document.getElementById('trace-source');
1071
+
1072
+ // ============================================
1073
+ // INIT — Load worlds, scenarios, narratives, adapters
1074
+ // ============================================
1075
+ async function init() {
1076
+ const [wRes, sRes, nRes, aRes] = await Promise.all([
1077
+ fetch('/api/worlds').then(r => r.json()),
1078
+ fetch('/api/scenarios').then(r => r.json()),
1079
+ fetch('/api/narratives').then(r => r.json()),
1080
+ fetch('/api/adapters').then(r => r.json()).catch(() => ({ adapters: [] })),
1081
+ ]);
1082
+
1083
+ worlds = wRes.worlds;
1084
+ scenarios = sRes.scenarios;
1085
+ narratives = nRes.narratives;
1086
+
1087
+ // Populate engine selector with live adapters
1088
+ (aRes.adapters || []).forEach(function(a) {
1089
+ const opt = document.createElement('option');
1090
+ opt.value = a.id;
1091
+ opt.textContent = a.label;
1092
+ engineSelect.appendChild(opt);
1093
+ });
1094
+
1095
+ // Populate world select
1096
+ worlds.forEach(w => {
1097
+ const opt = document.createElement('option');
1098
+ opt.value = w.id;
1099
+ opt.textContent = w.title;
1100
+ worldSelect.appendChild(opt);
1101
+ });
1102
+
1103
+ // Populate event select
1104
+ Object.entries(narratives).forEach(([id, ev]) => {
1105
+ const opt = document.createElement('option');
1106
+ opt.value = id;
1107
+ opt.textContent = ev.headline.slice(0, 40);
1108
+ eventSelect.appendChild(opt);
1109
+ });
1110
+
1111
+ // Populate scenario presets
1112
+ Object.entries(scenarios).forEach(([id, s]) => {
1113
+ const btn = document.createElement('button');
1114
+ btn.className = 'scenario-btn';
1115
+ btn.innerHTML = '<div class="stitle">' + s.title + '</div><div class="sdesc">' + s.description + '</div>';
1116
+ btn.onclick = () => loadScenario(id, s);
1117
+ scenarioListEl.appendChild(btn);
1118
+ });
1119
+
1120
+ // Select first world
1121
+ if (worlds.length > 0) selectWorld(worlds[0].id);
1122
+
1123
+ // Load saved variants
1124
+ await loadVariants();
1125
+
1126
+ // Connect SSE
1127
+ connectSSE();
1128
+ }
1129
+
1130
+ function selectWorld(worldId) {
1131
+ currentWorld = worlds.find(w => w.id === worldId);
1132
+ if (!currentWorld) return;
1133
+
1134
+ worldSelect.value = worldId;
1135
+ document.getElementById('world-thesis').textContent = '"' + currentWorld.thesis + '"';
1136
+
1137
+ // Render state variable controls
1138
+ if (currentWorld.stateVariables && currentWorld.stateVariables.length > 0) {
1139
+ stateVarsSection.style.display = '';
1140
+ stateVarsEl.innerHTML = '';
1141
+ currentWorld.stateVariables.forEach(sv => {
1142
+ const row = document.createElement('div');
1143
+ row.className = 'ctrl-row';
1144
+
1145
+ if (sv.type === 'number' && sv.range) {
1146
+ const step = sv.range.max <= 1 ? 0.01 : (sv.range.max <= 10 ? 0.1 : 1);
1147
+ row.innerHTML =
1148
+ '<div class="ctrl-label"><span>' + sv.label + '</span><span class="val" id="sv-val-' + sv.id + '">' + sv.default_value + '</span></div>' +
1149
+ '<input type="range" id="sv-' + sv.id + '" min="' + sv.range.min + '" max="' + sv.range.max + '" step="' + step + '" value="' + sv.default_value + '" data-sv="' + sv.id + '">';
1150
+ stateVarsEl.appendChild(row);
1151
+ const slider = row.querySelector('input');
1152
+ slider.addEventListener('input', () => {
1153
+ document.getElementById('sv-val-' + sv.id).textContent = slider.value;
1154
+ });
1155
+ } else if (sv.type === 'enum' && sv.enum_values) {
1156
+ row.innerHTML =
1157
+ '<div class="ctrl-label"><span>' + sv.label + '</span></div>' +
1158
+ '<select id="sv-' + sv.id + '" data-sv="' + sv.id + '">' +
1159
+ sv.enum_values.map(v => '<option value="' + v + '"' + (v === sv.default_value ? ' selected' : '') + '>' + v + '</option>').join('') +
1160
+ '</select>';
1161
+ stateVarsEl.appendChild(row);
1162
+ } else if (sv.type === 'boolean') {
1163
+ row.innerHTML =
1164
+ '<div class="toggle-row">' +
1165
+ '<div class="toggle' + (sv.default_value ? ' on' : '') + '" id="sv-' + sv.id + '" data-sv="' + sv.id + '"></div>' +
1166
+ '<span class="toggle-label">' + sv.label + '</span>' +
1167
+ '</div>';
1168
+ stateVarsEl.appendChild(row);
1169
+ const toggle = row.querySelector('.toggle');
1170
+ toggle.addEventListener('click', () => {
1171
+ toggle.classList.toggle('on');
1172
+ });
1173
+ }
1174
+ });
1175
+ } else {
1176
+ stateVarsSection.style.display = 'none';
1177
+ }
1178
+
1179
+ // Show invariants
1180
+ activeInvEl.innerHTML = currentWorld.invariants.map(inv =>
1181
+ '<div class="inv-item">[' + inv.id + '] ' + inv.description + '</div>'
1182
+ ).join('') + (currentWorld.gates || []).map(g =>
1183
+ '<div class="inv-item" style="color:' + (g.severity === 'critical' ? '#f87171' : '#fbbf24') + '">[' + g.id + '] ' + g.label + '</div>'
1184
+ ).join('');
1185
+ }
1186
+
1187
+ function loadScenario(id, scenario) {
1188
+ // Set world
1189
+ selectWorld(scenario.world);
1190
+ // Set events
1191
+ injectedEvents = scenario.events.slice();
1192
+ renderInjectedEvents();
1193
+ // Set rounds
1194
+ const r = scenario.rounds || 5;
1195
+ roundsSlider.value = Math.min(r, 12);
1196
+ roundsVal.textContent = Math.min(r, 12);
1197
+ }
1198
+
1199
+ // ============================================
1200
+ // NARRATIVE EVENT INJECTION
1201
+ // ============================================
1202
+ document.getElementById('add-event-btn').addEventListener('click', () => {
1203
+ const eventId = eventSelect.value;
1204
+ const round = parseInt(eventRoundInput.value);
1205
+ if (!eventId || isNaN(round)) return;
1206
+ injectedEvents.push(eventId + '@' + round);
1207
+ renderInjectedEvents();
1208
+ });
1209
+
1210
+ function renderInjectedEvents() {
1211
+ injectListEl.innerHTML = injectedEvents.map((ev, i) =>
1212
+ '<div class="inject-item"><span>' + ev + '</span><span class="remove" data-idx="' + i + '">x</span></div>'
1213
+ ).join('');
1214
+ injectListEl.querySelectorAll('.remove').forEach(el => {
1215
+ el.addEventListener('click', () => {
1216
+ injectedEvents.splice(parseInt(el.dataset.idx), 1);
1217
+ renderInjectedEvents();
1218
+ });
1219
+ });
1220
+ }
1221
+
1222
+ // ============================================
1223
+ // WORLD SELECT
1224
+ // ============================================
1225
+ worldSelect.addEventListener('change', () => selectWorld(worldSelect.value));
1226
+ roundsSlider.addEventListener('input', () => { roundsVal.textContent = roundsSlider.value; });
1227
+
1228
+ // ============================================
1229
+ // RUN SIMULATION
1230
+ // ============================================
1231
+ runBtn.addEventListener('click', async () => {
1232
+ if (!currentWorld) return;
1233
+ runBtn.disabled = true;
1234
+ runBtn.textContent = 'Running...';
1235
+
1236
+ // Reset viewer state
1237
+ totalInterventions = 0;
1238
+ baselineImpacts = [];
1239
+ governedImpacts = [];
1240
+ chartLabels = [];
1241
+ if (chart) { chart.destroy(); chart = null; }
1242
+ agentsEl.innerHTML = '';
1243
+ logEl.innerHTML = '';
1244
+ document.getElementById('m-stability').textContent = '--';
1245
+ document.getElementById('m-volatility').textContent = '--';
1246
+ document.getElementById('m-round').textContent = '--';
1247
+ document.getElementById('m-interventions').textContent = '0';
1248
+
1249
+ // Gather state overrides
1250
+ const stateOverrides = {};
1251
+ if (currentWorld.stateVariables) {
1252
+ currentWorld.stateVariables.forEach(sv => {
1253
+ const el = document.getElementById('sv-' + sv.id);
1254
+ if (!el) return;
1255
+ if (sv.type === 'number') stateOverrides[sv.id] = parseFloat(el.value);
1256
+ else if (sv.type === 'boolean') stateOverrides[sv.id] = el.classList.contains('on');
1257
+ else stateOverrides[sv.id] = el.value;
1258
+ });
1259
+ }
1260
+
1261
+ const selectedEngine = engineSelect.value;
1262
+
1263
+ try {
1264
+ if (selectedEngine === 'nv-sim') {
1265
+ // Built-in simulation
1266
+ const config = {
1267
+ worldId: currentWorld.id,
1268
+ stateOverrides,
1269
+ injectEvents: injectedEvents.length > 0 ? injectedEvents : undefined,
1270
+ rounds: parseInt(roundsSlider.value),
1271
+ };
1272
+ await fetch('/api/run-sim', {
1273
+ method: 'POST',
1274
+ headers: { 'Content-Type': 'application/json' },
1275
+ body: JSON.stringify(config),
1276
+ });
1277
+ } else {
1278
+ // Live adapter (external simulator)
1279
+ const payload = {
1280
+ adapterId: selectedEngine,
1281
+ worldId: currentWorld.id,
1282
+ stateOverrides,
1283
+ };
1284
+ await fetch('/api/run-live', {
1285
+ method: 'POST',
1286
+ headers: { 'Content-Type': 'application/json' },
1287
+ body: JSON.stringify(payload),
1288
+ });
1289
+ }
1290
+ } catch (err) {
1291
+ addLog('Error starting simulation: ' + err.message, 'block');
1292
+ runBtn.disabled = false;
1293
+ runBtn.textContent = 'Run Simulation';
1294
+ }
1295
+ });
1296
+
1297
+ // ============================================
1298
+ // SSE — Live stream
1299
+ // ============================================
1300
+ function connectSSE() {
1301
+ const es = new EventSource('/events');
1302
+ es.onmessage = (e) => {
1303
+ try { handleEvent(JSON.parse(e.data)); } catch {}
1304
+ };
1305
+ es.onerror = () => {};
1306
+ }
1307
+
1308
+ function initChart() {
1309
+ if (typeof Chart === 'undefined') return;
1310
+ const ctx = document.getElementById('chart');
1311
+ chart = new Chart(ctx, {
1312
+ type: 'line',
1313
+ data: {
1314
+ labels: chartLabels,
1315
+ datasets: [
1316
+ { label: 'Baseline', data: baselineImpacts, borderColor: '#ef4444', backgroundColor: 'rgba(239,68,68,0.1)', fill: true, tension: 0.3, pointRadius: 3 },
1317
+ { label: 'Governed', data: governedImpacts, borderColor: '#4ade80', backgroundColor: 'rgba(74,222,128,0.1)', fill: true, tension: 0.3, pointRadius: 3 },
1318
+ ]
1319
+ },
1320
+ options: {
1321
+ animation: { duration: 400 },
1322
+ responsive: true,
1323
+ maintainAspectRatio: false,
1324
+ plugins: { legend: { labels: { color: '#888', font: { family: 'monospace', size: 10 } } } },
1325
+ scales: {
1326
+ x: { ticks: { color: '#555' }, grid: { color: '#1a1a1a' } },
1327
+ y: { ticks: { color: '#555' }, grid: { color: '#1a1a1a' }, min: -1, max: 1 }
1328
+ }
1329
+ }
1330
+ });
1331
+ }
1332
+
1333
+ function addLog(msg, cls) {
1334
+ const entry = document.createElement('div');
1335
+ entry.style.cssText = 'font-size:10px;color:#666;padding:3px 0;';
1336
+ entry.textContent = msg;
1337
+ logEl.prepend(entry);
1338
+ }
1339
+
1340
+ function addTraceRound(event) {
1341
+ const el = document.createElement('div');
1342
+ el.className = 'trace-round';
1343
+
1344
+ const phaseCls = event.phase === 'baseline' ? 'baseline' : 'governed';
1345
+ const volatilityPct = (event.maxVolatility * 100).toFixed(0);
1346
+ el.innerHTML = '<div class="trace-round-header" onclick="this.nextElementSibling.dataset.collapsed = this.nextElementSibling.dataset.collapsed === \\'true\\' ? \\'false\\' : \\'true\\'">' +
1347
+ '<span class="trace-phase ' + phaseCls + '">' + event.phase + '</span>' +
1348
+ '<span class="trace-round-label">Round ' + event.round + ' / ' + event.totalRounds + '</span>' +
1349
+ '<span class="trace-round-metrics">' +
1350
+ '<span>avg: ' + event.avgImpact.toFixed(2) + '</span>' +
1351
+ '<span>vol: ' + volatilityPct + '%</span>' +
1352
+ (event.interventionCount > 0 ? '<span style="color:#fbbf24">' + event.interventionCount + ' interventions</span>' : '') +
1353
+ '</span>' +
1354
+ '</div>';
1355
+
1356
+ let bodyHtml = '';
1357
+
1358
+ // 1) Narrative events injected this round
1359
+ const roundEvents = (typeof narrativeEventsByRound !== 'undefined' && narrativeEventsByRound[event.round]) || [];
1360
+ if (roundEvents.length > 0) {
1361
+ bodyHtml += '<div class="trace-section"><div class="trace-section-label event">Event Injection</div>';
1362
+ roundEvents.forEach(function(ev) {
1363
+ bodyHtml += '<div class="trace-event-item">' + ev.headline + ' <span class="trace-event-severity ' + ev.severity + '">' + ev.severity + '</span></div>';
1364
+ });
1365
+ bodyHtml += '</div><div class="trace-arrow">↓</div>';
1366
+ }
1367
+
1368
+ // 2) Agent reactions (cap at 30 to prevent DOM bloat with thousands of agents)
1369
+ var MAX_TRACE_AGENTS = 30;
1370
+ bodyHtml += '<div class="trace-section"><div class="trace-section-label agents">Agent Reactions (' + event.reactions.length + ')</div>';
1371
+ event.reactions.slice(0, MAX_TRACE_AGENTS).forEach(function(r) {
1372
+ const impactCls = r.impact >= 0 ? 'positive' : 'negative';
1373
+ bodyHtml += '<div class="trace-agent-item">' +
1374
+ '<span class="trace-agent-name">' + r.stakeholder_id + '</span>' +
1375
+ '<span class="trace-agent-action">' + r.reaction + '</span>' +
1376
+ '<span class="trace-agent-impact ' + impactCls + '">' + r.impact.toFixed(2) + '</span>' +
1377
+ '</div>';
1378
+ });
1379
+ if (event.reactions.length > MAX_TRACE_AGENTS) {
1380
+ var traceRemaining = event.reactions.length - MAX_TRACE_AGENTS;
1381
+ bodyHtml += '<div style="font-size:10px;color:#444;padding:3px 0 3px 11px;border-left:2px solid #3b82f6;margin-left:2px">+ ' + traceRemaining + ' more agents</div>';
1382
+ }
1383
+ bodyHtml += '</div>';
1384
+
1385
+ // 3) Governance interventions
1386
+ const governed = event.reactions.filter(function(r) { return r.verdict && r.verdict.status !== 'ALLOW'; });
1387
+ if (governed.length > 0) {
1388
+ bodyHtml += '<div class="trace-arrow">↓</div>';
1389
+ bodyHtml += '<div class="trace-section"><div class="trace-section-label governance">Governance Interventions</div>';
1390
+ governed.forEach(function(r) {
1391
+ bodyHtml += '<div class="trace-gov-item">' +
1392
+ '<span class="verdict ' + r.verdict.status + '">' + r.verdict.status + '</span>' +
1393
+ (r.verdict.ruleId ? '<span class="trace-gov-rule">[' + r.verdict.ruleId + ']</span>' : '') +
1394
+ '<span class="trace-gov-reason">' + r.stakeholder_id + ': ' + (r.verdict.reason || '') + '</span>' +
1395
+ '</div>';
1396
+ });
1397
+ bodyHtml += '</div>';
1398
+ }
1399
+
1400
+ // 4) Emergent dynamics
1401
+ if (event.dynamics && event.dynamics.length > 0) {
1402
+ bodyHtml += '<div class="trace-arrow">↓</div>';
1403
+ bodyHtml += '<div class="trace-section"><div class="trace-section-label" style="color:#7c3aed">Emergent Dynamics</div>';
1404
+ event.dynamics.forEach(function(d) {
1405
+ bodyHtml += '<div class="trace-dynamics">' + d + '</div>';
1406
+ });
1407
+ bodyHtml += '</div>';
1408
+ }
1409
+
1410
+ const bodyEl = document.createElement('div');
1411
+ bodyEl.className = 'trace-body';
1412
+ bodyEl.dataset.collapsed = 'false';
1413
+ bodyEl.innerHTML = bodyHtml;
1414
+ el.appendChild(bodyEl);
1415
+
1416
+ logEl.prepend(el);
1417
+ }
1418
+
1419
+ function renderAgents(reactions) {
1420
+ var MAX_AGENT_ROWS = 50;
1421
+ var shown = reactions.slice(0, MAX_AGENT_ROWS);
1422
+ var html = shown.map(r => {
1423
+ const pct = Math.abs(r.impact) * 50;
1424
+ const cls = r.impact >= 0 ? 'positive' : 'negative';
1425
+ const dir = r.impact >= 0 ? 'right:50%;width:' + pct + '%' : 'left:50%;width:' + pct + '%';
1426
+ const verdictHtml = r.verdict ? ' <span class="verdict ' + r.verdict.status + '">' + r.verdict.status + '</span>' : '';
1427
+ return '<div class="agent-row">' +
1428
+ '<span class="agent-name">' + r.stakeholder_id + verdictHtml + '</span>' +
1429
+ '<div class="impact-bar-bg"><div class="center-line"></div><div class="impact-bar ' + cls + '" style="' + dir + '"></div></div>' +
1430
+ '<span class="impact-val">' + r.impact.toFixed(2) + '</span>' +
1431
+ '</div>';
1432
+ }).join('');
1433
+ if (reactions.length > MAX_AGENT_ROWS) {
1434
+ var remaining = reactions.length - MAX_AGENT_ROWS;
1435
+ var avgImpact = reactions.slice(MAX_AGENT_ROWS).reduce(function(s, r) { return s + r.impact; }, 0) / remaining;
1436
+ html += '<div class="agent-row" style="color:#555;font-size:10px;justify-content:center">+ ' + remaining + ' more agents (avg impact: ' + avgImpact.toFixed(2) + ')</div>';
1437
+ }
1438
+ agentsEl.innerHTML = html;
1439
+ }
1440
+
1441
+ function handleEvent(event) {
1442
+ if (event.type === 'meta') {
1443
+ statusEl.className = 'status live';
1444
+ statusEl.textContent = 'LIVE';
1445
+ // Show simulation source
1446
+ const src = event.source || 'nv-sim';
1447
+ if (src !== 'nv-sim') {
1448
+ traceSourceEl.textContent = '● ' + src.toUpperCase() + ' (LIVE)';
1449
+ traceSourceEl.style.color = '#4ade80';
1450
+ engineStatusEl.textContent = 'Streaming from ' + src;
1451
+ engineStatusEl.style.color = '#4ade80';
1452
+ } else {
1453
+ traceSourceEl.textContent = '';
1454
+ engineStatusEl.textContent = '';
1455
+ }
1456
+ addLog('Simulation started: ' + event.agents.length + ' agents, ' + event.totalRounds + ' rounds' + (src !== 'nv-sim' ? ' [source: ' + src + ']' : ''));
1457
+ resetShiftTracker();
1458
+ // Store narrative events by round for trace rendering
1459
+ narrativeEventsByRound = {};
1460
+ (event.narrativeEvents || []).forEach(function(ev) {
1461
+ if (!narrativeEventsByRound[ev.round]) narrativeEventsByRound[ev.round] = [];
1462
+ narrativeEventsByRound[ev.round].push(ev);
1463
+ });
1464
+ activeInvEl.innerHTML = event.invariants.map(inv =>
1465
+ '<div class="inv-item">[' + inv.id + '] ' + inv.description + '</div>'
1466
+ ).join('') + event.gates.map(g =>
1467
+ '<div class="inv-item" style="color:' + (g.severity === 'critical' ? '#f87171' : '#fbbf24') + '">[' + g.id + '] ' + g.label + '</div>'
1468
+ ).join('');
1469
+ initChart();
1470
+ }
1471
+
1472
+ if (event.type === 'round') {
1473
+ if (event.phase === 'baseline') {
1474
+ chartLabels.push('R' + event.round);
1475
+ baselineImpacts.push(event.avgImpact);
1476
+ } else {
1477
+ governedImpacts.push(event.avgImpact);
1478
+ }
1479
+ if (chart) chart.update();
1480
+
1481
+ renderAgents(event.reactions);
1482
+ document.getElementById('m-round').textContent = event.round + '/' + event.totalRounds;
1483
+ document.getElementById('m-volatility').textContent = (event.maxVolatility * 100).toFixed(0) + '%';
1484
+ document.getElementById('m-volatility').parentElement.className = 'metric-box ' + (event.maxVolatility > 0.6 ? 'bad' : event.maxVolatility > 0.4 ? 'warn' : 'good');
1485
+
1486
+ totalInterventions += event.interventionCount;
1487
+ document.getElementById('m-interventions').textContent = totalInterventions;
1488
+
1489
+ // Track system shifts for the card
1490
+ trackShift(event);
1491
+
1492
+ // Render structured trace entry instead of flat log line
1493
+ addTraceRound(event);
1494
+ }
1495
+
1496
+ if (event.type === 'complete') {
1497
+ statusEl.className = 'status complete';
1498
+ statusEl.textContent = 'COMPLETE';
1499
+ const r = event.result;
1500
+ if (r.governed) {
1501
+ document.getElementById('m-stability').textContent = (r.governed.metrics.stabilityScore * 100).toFixed(0) + '%';
1502
+ document.getElementById('m-stability').parentElement.className = 'metric-box ' + (r.governed.metrics.stabilityScore > 0.7 ? 'good' : 'warn');
1503
+ addLog('Complete. Governance effectiveness: ' + (r.comparison.governanceEffectiveness * 100).toFixed(0) + '%');
1504
+ renderSystemShift(r);
1505
+ lastSimResult = {
1506
+ stability: r.governed.metrics.stabilityScore,
1507
+ volatility: r.governed.metrics.maxVolatility,
1508
+ collapseProbability: r.governed.metrics.collapseProbability,
1509
+ governanceEffectiveness: r.comparison.governanceEffectiveness,
1510
+ };
1511
+ }
1512
+ runBtn.disabled = false;
1513
+ runBtn.textContent = 'Run Simulation';
1514
+ }
1515
+ }
1516
+
1517
+ // ============================================
1518
+ // SYSTEM SHIFT CARD — The Demo Moment
1519
+ // ============================================
1520
+ const ssCard = document.getElementById('system-shift');
1521
+ const ssRuleEl = document.getElementById('ss-rule');
1522
+ const ssAdaptRateEl = document.getElementById('ss-adapt-rate');
1523
+ const ssAdaptDescEl = document.getElementById('ss-adapt-desc');
1524
+ const ssShiftsEl = document.getElementById('ss-shifts');
1525
+ const ssPatternsEl = document.getElementById('ss-patterns');
1526
+ const ssImpactsEl = document.getElementById('ss-impacts');
1527
+ const ssNarrativeEl = document.getElementById('ss-narrative');
1528
+
1529
+ // Raw detail toggle
1530
+ document.getElementById('ss-raw-toggle').addEventListener('click', function() {
1531
+ this.classList.toggle('open');
1532
+ document.getElementById('ss-raw-detail').classList.toggle('open');
1533
+ });
1534
+
1535
+ let shiftTracker = { blocks: 0, total: 0, shifts: {}, patterns: [], baselineVol: 0, governedVol: 0, narrative: '', rawGoverned: [] };
1536
+
1537
+ function resetShiftTracker() {
1538
+ shiftTracker = { blocks: 0, total: 0, shifts: {}, patterns: [], baselineVol: 0, governedVol: 0, narrative: '', rawGoverned: [] };
1539
+ ssCard.classList.remove('visible');
1540
+ var rawToggle = document.getElementById('ss-raw-toggle');
1541
+ var rawDetail = document.getElementById('ss-raw-detail');
1542
+ if (rawToggle) rawToggle.classList.remove('open');
1543
+ if (rawDetail) rawDetail.classList.remove('open');
1544
+ }
1545
+
1546
+ function trackShift(event) {
1547
+ if (!event.reactions) return;
1548
+ const governed = event.reactions.filter(function(r) { return r.verdict && r.verdict.status !== 'ALLOW'; });
1549
+ shiftTracker.blocks += governed.length;
1550
+ shiftTracker.total += event.reactions.length;
1551
+ governed.forEach(function(r) {
1552
+ const key = r.verdict.status + ': ' + (r.reaction || 'adapted');
1553
+ shiftTracker.shifts[key] = (shiftTracker.shifts[key] || 0) + 1;
1554
+ // Store raw governed reactions for detail view
1555
+ shiftTracker.rawGoverned.push({
1556
+ agent: r.stakeholder_id,
1557
+ action: r.reaction,
1558
+ status: r.verdict.status,
1559
+ reason: r.verdict.reason || '',
1560
+ round: event.round,
1561
+ });
1562
+ });
1563
+ if (event.phase === 'baseline') shiftTracker.baselineVol = Math.max(shiftTracker.baselineVol, event.maxVolatility || 0);
1564
+ if (event.phase === 'governed') shiftTracker.governedVol = Math.max(shiftTracker.governedVol, event.maxVolatility || 0);
1565
+ if (event.dynamics) {
1566
+ event.dynamics.forEach(function(d) {
1567
+ if (shiftTracker.patterns.indexOf(d) === -1) shiftTracker.patterns.push(d);
1568
+ });
1569
+ }
1570
+ }
1571
+
1572
+ function renderSystemShift(result) {
1573
+ if (shiftTracker.blocks === 0) return;
1574
+
1575
+ var adaptRate = shiftTracker.total > 0 ? Math.round((shiftTracker.blocks / shiftTracker.total) * 100) : 0;
1576
+
1577
+ // Determine rule label from world
1578
+ var ruleName = 'Governance Rules Active';
1579
+ if (currentWorld && currentWorld.invariants) {
1580
+ var names = currentWorld.invariants.map(function(inv) { return inv.description || inv.id; });
1581
+ ruleName = names.slice(0, 2).join(' + ');
1582
+ }
1583
+
1584
+ ssRuleEl.textContent = ruleName;
1585
+ ssAdaptRateEl.textContent = adaptRate + '%';
1586
+ ssAdaptDescEl.textContent = 'adaptation across ' + shiftTracker.total.toLocaleString() + ' agents';
1587
+
1588
+ // Scale line — proves this is real
1589
+ var scaleEl = document.getElementById('ss-scale');
1590
+ scaleEl.innerHTML = '<strong>' + shiftTracker.blocks.toLocaleString() + '</strong> actions reshaped out of <strong>' + shiftTracker.total.toLocaleString() + '</strong> total';
1591
+
1592
+ // Shifts — show top 5 by count, collapse the rest
1593
+ var shiftHtml = '';
1594
+ var shiftKeys = Object.keys(shiftTracker.shifts).sort(function(a, b) {
1595
+ return shiftTracker.shifts[b] - shiftTracker.shifts[a];
1596
+ });
1597
+ var MAX_SHIFTS = 5;
1598
+ shiftKeys.slice(0, MAX_SHIFTS).forEach(function(k) {
1599
+ shiftHtml += '<div class="ss-shift-item"><span class="ss-shift-arrow">→</span> ' + k + ' (' + shiftTracker.shifts[k] + ' agents)</div>';
1600
+ });
1601
+ if (shiftKeys.length > MAX_SHIFTS) {
1602
+ var remaining = shiftKeys.slice(MAX_SHIFTS).reduce(function(sum, k) { return sum + shiftTracker.shifts[k]; }, 0);
1603
+ shiftHtml += '<div class="ss-shift-item" style="color:#555">+ ' + (shiftKeys.length - MAX_SHIFTS) + ' more patterns (' + remaining + ' agents)</div>';
1604
+ }
1605
+ ssShiftsEl.innerHTML = shiftHtml;
1606
+
1607
+ // Patterns
1608
+ var patternHtml = '';
1609
+ if (shiftTracker.patterns.length > 0) {
1610
+ shiftTracker.patterns.forEach(function(p) {
1611
+ patternHtml += '<span class="ss-pattern-tag">' + p + '</span>';
1612
+ });
1613
+ } else {
1614
+ // Infer patterns from data
1615
+ if (shiftTracker.blocks >= 3) patternHtml += '<span class="ss-pattern-tag">Coordinated Holding</span>';
1616
+ if (shiftTracker.blocks >= 2) patternHtml += '<span class="ss-pattern-tag">Panic Suppression</span>';
1617
+ if (shiftTracker.governedVol < shiftTracker.baselineVol) patternHtml += '<span class="ss-pattern-tag">Stability Shift</span>';
1618
+ }
1619
+ ssPatternsEl.innerHTML = patternHtml || '<span style="font-size:11px;color:#555">No emergent patterns detected</span>';
1620
+
1621
+ // Impact
1622
+ var impactHtml = '';
1623
+ var volDelta = shiftTracker.governedVol - shiftTracker.baselineVol;
1624
+ impactHtml += '<div class="ss-impact-row"><span>Volatility</span><span class="ss-impact-delta' + (volDelta > 0 ? ' negative' : '') + '">' +
1625
+ (shiftTracker.baselineVol * 100).toFixed(0) + '% → ' + (shiftTracker.governedVol * 100).toFixed(0) + '%</span></div>';
1626
+
1627
+ if (result && result.governed && result.baseline) {
1628
+ var stabDelta = result.governed.metrics.stabilityScore - result.baseline.metrics.stabilityScore;
1629
+ impactHtml += '<div class="ss-impact-row"><span>Stability</span><span class="ss-impact-delta">' +
1630
+ (result.baseline.metrics.stabilityScore * 100).toFixed(0) + '% → ' + (result.governed.metrics.stabilityScore * 100).toFixed(0) + '%</span></div>';
1631
+
1632
+ var cascadeAvoided = result.governed.metrics.collapseProbability < result.baseline.metrics.collapseProbability;
1633
+ if (cascadeAvoided) {
1634
+ impactHtml += '<div class="ss-impact-row"><span>Cascade</span><span class="ss-impact-delta">Avoided</span></div>';
1635
+ }
1636
+ }
1637
+ ssImpactsEl.innerHTML = impactHtml;
1638
+
1639
+ // Narrative — tight cause → shift → outcome
1640
+ var topShift = shiftKeys.length > 0 ? shiftKeys[0].split(': ')[1] || 'adapted' : 'adapted';
1641
+ var narrative = '';
1642
+
1643
+ // What was the rule?
1644
+ narrative += ruleName + '. ';
1645
+
1646
+ // What shifted?
1647
+ narrative += shiftTracker.blocks + ' of ' + shiftTracker.total + ' agents reorganized';
1648
+ if (topShift !== 'adapted') narrative += ' — most shifted to ' + topShift;
1649
+ narrative += '. ';
1650
+
1651
+ // What emerged?
1652
+ if (shiftTracker.patterns.length > 0) {
1653
+ narrative += 'Pattern: ' + shiftTracker.patterns.slice(0, 2).join(', ') + '. ';
1654
+ }
1655
+
1656
+ // What was the outcome?
1657
+ if (volDelta < 0) narrative += 'Volatility dropped ' + Math.abs(volDelta * 100).toFixed(0) + '%.';
1658
+ else if (volDelta === 0) narrative += 'System held steady.';
1659
+ else narrative += 'Volatility increased ' + (volDelta * 100).toFixed(0) + '% — rules need tuning.';
1660
+
1661
+ ssNarrativeEl.textContent = narrative;
1662
+
1663
+ // Raw detail — virtualized list of governed actions (collapsed by default)
1664
+ var rawListEl = document.getElementById('ss-raw-list');
1665
+ var MAX_RAW = 100;
1666
+ var rawItems = shiftTracker.rawGoverned.slice(0, MAX_RAW);
1667
+ var rawHtml = rawItems.map(function(r) {
1668
+ return '<div class="ss-raw-item">' +
1669
+ '<span class="raw-agent">' + r.agent + '</span>' +
1670
+ '<span class="raw-action">R' + r.round + ': ' + r.action + '</span>' +
1671
+ '<span class="raw-verdict ' + r.status + '">' + r.status + '</span>' +
1672
+ '</div>';
1673
+ }).join('');
1674
+ if (shiftTracker.rawGoverned.length > MAX_RAW) {
1675
+ rawHtml += '<div style="font-size:10px;color:#444;padding:4px 0">+ ' + (shiftTracker.rawGoverned.length - MAX_RAW) + ' more governed actions</div>';
1676
+ }
1677
+ rawListEl.innerHTML = rawHtml;
1678
+
1679
+ ssCard.classList.add('visible');
1680
+ }
1681
+
1682
+ // ============================================
1683
+ // WORLD VARIANTS — Save / Load / Delete
1684
+ // ============================================
1685
+ const variantListEl = document.getElementById('variant-list');
1686
+ const saveBtn = document.getElementById('save-btn');
1687
+ const saveForm = document.getElementById('save-form');
1688
+ const confirmSaveBtn = document.getElementById('confirm-save-btn');
1689
+ const cancelSaveBtn = document.getElementById('cancel-save-btn');
1690
+ const variantNameInput = document.getElementById('variant-name');
1691
+ const variantDescInput = document.getElementById('variant-desc');
1692
+ let lastSimResult = null;
1693
+
1694
+ saveBtn.addEventListener('click', () => {
1695
+ saveForm.style.display = saveForm.style.display === 'none' ? '' : 'none';
1696
+ variantNameInput.focus();
1697
+ });
1698
+
1699
+ cancelSaveBtn.addEventListener('click', () => {
1700
+ saveForm.style.display = 'none';
1701
+ });
1702
+
1703
+ confirmSaveBtn.addEventListener('click', async () => {
1704
+ const name = variantNameInput.value.trim();
1705
+ if (!name || !currentWorld) return;
1706
+
1707
+ // Gather current state overrides
1708
+ const stateOverrides = {};
1709
+ if (currentWorld.stateVariables) {
1710
+ currentWorld.stateVariables.forEach(sv => {
1711
+ const el = document.getElementById('sv-' + sv.id);
1712
+ if (!el) return;
1713
+ if (sv.type === 'number') stateOverrides[sv.id] = parseFloat(el.value);
1714
+ else if (sv.type === 'boolean') stateOverrides[sv.id] = el.classList.contains('on');
1715
+ else stateOverrides[sv.id] = el.value;
1716
+ });
1717
+ }
1718
+
1719
+ const payload = {
1720
+ name,
1721
+ description: variantDescInput.value.trim(),
1722
+ baseWorld: currentWorld.id,
1723
+ stateOverrides,
1724
+ events: injectedEvents.slice(),
1725
+ rounds: parseInt(roundsSlider.value),
1726
+ lastResult: lastSimResult,
1727
+ };
1728
+
1729
+ try {
1730
+ const resp = await fetch('/api/save-variant', {
1731
+ method: 'POST',
1732
+ headers: { 'Content-Type': 'application/json' },
1733
+ body: JSON.stringify(payload),
1734
+ });
1735
+ const data = await resp.json();
1736
+ if (data.status === 'saved') {
1737
+ addLog('Variant saved: ' + data.variant.name + ' (' + data.variant.id + ')', '');
1738
+ saveForm.style.display = 'none';
1739
+ variantNameInput.value = '';
1740
+ variantDescInput.value = '';
1741
+ await loadVariants();
1742
+ }
1743
+ } catch (err) {
1744
+ addLog('Error saving variant: ' + err.message, 'block');
1745
+ }
1746
+ });
1747
+
1748
+ async function loadVariants() {
1749
+ try {
1750
+ const resp = await fetch('/api/variants');
1751
+ const data = await resp.json();
1752
+ renderVariants(data.variants || []);
1753
+ } catch {}
1754
+ }
1755
+
1756
+ function renderVariants(variants) {
1757
+ if (variants.length === 0) {
1758
+ variantListEl.innerHTML = '<div style="font-size:11px;color:#333">No saved variants yet</div>';
1759
+ return;
1760
+ }
1761
+ variantListEl.innerHTML = variants.map(v => {
1762
+ const resultHtml = v.lastResult
1763
+ ? '<span class="vresult">Stability: ' + (v.lastResult.stability * 100).toFixed(0) + '% | Effectiveness: ' + (v.lastResult.governanceEffectiveness * 100).toFixed(0) + '%</span>'
1764
+ : '<span style="color:#555">Not yet run</span>';
1765
+ return '<div class="variant-card" data-vid="' + v.id + '">' +
1766
+ '<div class="vname">' + v.name + '</div>' +
1767
+ (v.description ? '<div class="vdesc">' + v.description + '</div>' : '') +
1768
+ '<span class="vbase">' + v.baseWorld + '</span>' +
1769
+ '<div class="vmeta">' + resultHtml + ' | ' + v.events.length + ' events | ' + v.rounds + ' rounds</div>' +
1770
+ '<span class="vdelete" data-vid="' + v.id + '">delete</span>' +
1771
+ '</div>';
1772
+ }).join('');
1773
+
1774
+ // Bind load handlers
1775
+ variantListEl.querySelectorAll('.variant-card').forEach(card => {
1776
+ card.addEventListener('click', (e) => {
1777
+ if (e.target.classList.contains('vdelete')) return;
1778
+ const vid = card.dataset.vid;
1779
+ const v = variants.find(x => x.id === vid);
1780
+ if (v) loadVariant(v);
1781
+ });
1782
+ });
1783
+
1784
+ // Bind delete handlers
1785
+ variantListEl.querySelectorAll('.vdelete').forEach(el => {
1786
+ el.addEventListener('click', async (e) => {
1787
+ e.stopPropagation();
1788
+ const vid = el.dataset.vid;
1789
+ if (!confirm('Delete variant "' + vid + '"?')) return;
1790
+ try {
1791
+ await fetch('/api/delete-variant/' + vid, { method: 'DELETE' });
1792
+ addLog('Variant deleted: ' + vid, '');
1793
+ await loadVariants();
1794
+ } catch {}
1795
+ });
1796
+ });
1797
+ }
1798
+
1799
+ function loadVariant(variant) {
1800
+ // Set world
1801
+ selectWorld(variant.baseWorld);
1802
+
1803
+ // Apply state overrides
1804
+ if (variant.stateOverrides && currentWorld && currentWorld.stateVariables) {
1805
+ currentWorld.stateVariables.forEach(sv => {
1806
+ if (sv.id in variant.stateOverrides) {
1807
+ const el = document.getElementById('sv-' + sv.id);
1808
+ if (!el) return;
1809
+ if (sv.type === 'boolean') {
1810
+ const val = variant.stateOverrides[sv.id];
1811
+ if (val && !el.classList.contains('on')) el.classList.add('on');
1812
+ if (!val && el.classList.contains('on')) el.classList.remove('on');
1813
+ } else {
1814
+ el.value = variant.stateOverrides[sv.id];
1815
+ // Update value display for sliders
1816
+ const valEl = document.getElementById('sv-val-' + sv.id);
1817
+ if (valEl) valEl.textContent = variant.stateOverrides[sv.id];
1818
+ }
1819
+ }
1820
+ });
1821
+ }
1822
+
1823
+ // Set events
1824
+ injectedEvents = variant.events.slice();
1825
+ renderInjectedEvents();
1826
+
1827
+ // Set rounds
1828
+ roundsSlider.value = Math.min(variant.rounds, 12);
1829
+ roundsVal.textContent = Math.min(variant.rounds, 12);
1830
+
1831
+ addLog('Loaded variant: ' + variant.name, '');
1832
+ }
1833
+
1834
+ // ============================================
1835
+ // CAPTURE LAST RESULT FOR VARIANT SAVING
1836
+ // ============================================
1837
+ // (override handleEvent to capture results)
1838
+ const _origHandleEvent = handleEvent;
1839
+ handleEvent = function(event) {
1840
+ _origHandleEvent(event);
1841
+ if (event.type === 'complete' && event.result && event.result.governed) {
1842
+ lastSimResult = {
1843
+ stability: event.result.governed.metrics.stabilityScore,
1844
+ volatility: event.result.governed.metrics.maxVolatility,
1845
+ collapseProbability: event.result.governed.metrics.collapseProbability,
1846
+ governanceEffectiveness: event.result.comparison.governanceEffectiveness,
1847
+ };
1848
+ }
1849
+ };
1850
+
1851
+ // ============================================
1852
+ // BOOT
1853
+ // ============================================
1854
+ init();
1855
+ <\/script>
1856
+ </body>
1857
+ </html>`;