@neuroverseos/nv-sim 0.1.4 → 0.1.7

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.
Files changed (44) hide show
  1. package/README.md +260 -6
  2. package/dist/adapters/mirofish.js +461 -0
  3. package/dist/adapters/scienceclaw.js +750 -0
  4. package/dist/assets/index-CHmUN8s0.js +532 -0
  5. package/dist/assets/index-DWgMnB7I.css +1 -0
  6. package/dist/assets/{reportEngine-BfteK4MN.js → reportEngine-BVdQ2_nW.js} +1 -1
  7. package/dist/components/ConstraintsPanel.js +11 -0
  8. package/dist/components/StakeholderBuilder.js +32 -0
  9. package/dist/components/ui/badge.js +24 -0
  10. package/dist/components/ui/button.js +70 -0
  11. package/dist/components/ui/card.js +57 -0
  12. package/dist/components/ui/input.js +44 -0
  13. package/dist/components/ui/label.js +45 -0
  14. package/dist/components/ui/select.js +70 -0
  15. package/dist/engine/aiProvider.js +427 -2
  16. package/dist/engine/auditTrace.js +352 -0
  17. package/dist/engine/behavioralAnalysis.js +605 -0
  18. package/dist/engine/cli.js +1087 -13
  19. package/dist/engine/dynamicsGovernance.js +588 -0
  20. package/dist/engine/fullGovernedLoop.js +367 -0
  21. package/dist/engine/governedSimulation.js +77 -6
  22. package/dist/engine/index.js +41 -1
  23. package/dist/engine/liveVisualizer.js +1961 -197
  24. package/dist/engine/metrics/science.metrics.js +335 -0
  25. package/dist/engine/policyEnforcement.js +1611 -0
  26. package/dist/engine/policyEngine.js +799 -0
  27. package/dist/engine/primeRadiant.js +540 -0
  28. package/dist/engine/scenarioComparison.js +463 -0
  29. package/dist/engine/swarmSimulation.js +54 -1
  30. package/dist/engine/worldComparison.js +164 -0
  31. package/dist/engine/worldStorage.js +232 -0
  32. package/dist/index.html +2 -2
  33. package/dist/lib/reasoningEngine.js +290 -0
  34. package/dist/lib/simulationAdapter.js +686 -0
  35. package/dist/lib/swarmParser.js +291 -0
  36. package/dist/lib/types.js +2 -0
  37. package/dist/lib/utils.js +8 -0
  38. package/dist/runtime/govern.js +473 -0
  39. package/dist/runtime/index.js +75 -0
  40. package/dist/runtime/types.js +11 -0
  41. package/package.json +5 -2
  42. package/dist/assets/index-DHKd4rcV.js +0 -338
  43. package/dist/assets/index-SyyA3z3U.css +0 -1
  44. package/dist/assets/swarmSimulation-DHDqjfMa.js +0 -1
@@ -48,6 +48,7 @@ const fs = __importStar(require("fs"));
48
48
  const path = __importStar(require("path"));
49
49
  const swarmSimulation_1 = require("./swarmSimulation");
50
50
  const worldBridge_1 = require("./worldBridge");
51
+ const auditTrace_1 = require("./auditTrace");
51
52
  const narrativeInjection_1 = require("./narrativeInjection");
52
53
  const scenarioLibrary_1 = require("./scenarioLibrary");
53
54
  const liveAdapter_1 = require("./liveAdapter");
@@ -97,9 +98,252 @@ function deleteVariant(variantId) {
97
98
  * The UI sends POST /run-sim with world parameters.
98
99
  * The server runs the simulation and streams results via SSE.
99
100
  */
101
+ /**
102
+ * Parse a single plain-English rule into a guard definition.
103
+ * No LLM needed — pattern matching on common financial governance phrases.
104
+ */
105
+ function parseNaturalLanguageRule(line, index) {
106
+ const lower = line.toLowerCase().trim();
107
+ if (!lower)
108
+ return null;
109
+ // Determine enforcement action
110
+ let enforcement = "block";
111
+ if (/^(allow|permit|enable)\b/.test(lower))
112
+ enforcement = "allow";
113
+ else if (/^(pause|review|flag|hold|require.*review|require.*approval)\b/.test(lower))
114
+ enforcement = "pause";
115
+ else if (/^(block|ban|prevent|prohibit|stop|forbid|disallow|no\b|don.t allow)\b/.test(lower))
116
+ enforcement = "block";
117
+ else if (/^limit\b/.test(lower))
118
+ enforcement = "block";
119
+ // Extract intent patterns from the rule text
120
+ const patterns = [];
121
+ const actionWords = {
122
+ "panic sell": ["panic_sell", "panic sell", "panic selling"],
123
+ "panic buy": ["panic_buy", "panic buy", "panic buying"],
124
+ "short sell": ["short", "short_sell", "short selling", "shorting"],
125
+ "leverage": ["increase_leverage", "increase leverage", "max leverage", "excessive leverage"],
126
+ "large trade": ["large_trade", "large trade", "bulk trade", "big trade"],
127
+ "aggressive": ["aggressive_buy", "aggressive buy", "aggressive_short", "aggressive short"],
128
+ "flash": ["flash_trade", "flash trade", "high_frequency"],
129
+ "margin": ["margin_call", "margin call", "increase_margin"],
130
+ "liquidat": ["liquidate", "liquidation", "force_liquidate"],
131
+ "hedge": ["hedge", "hedging", "hedge_position"],
132
+ "buy": ["buy", "aggressive_buy"],
133
+ "sell": ["sell", "selling"],
134
+ "trade": ["trade", "trading"],
135
+ "withdraw": ["withdraw", "withdrawal", "bank_run"],
136
+ "circuit breaker": ["circuit_breaker", "halt_trading", "trading_halt"],
137
+ };
138
+ for (const [keyword, intents] of Object.entries(actionWords)) {
139
+ if (lower.includes(keyword)) {
140
+ patterns.push(...intents);
141
+ }
142
+ }
143
+ // Fallback: extract quoted terms or the main noun phrase
144
+ if (patterns.length === 0) {
145
+ const quoted = line.match(/"([^"]+)"/g);
146
+ if (quoted) {
147
+ quoted.forEach(q => {
148
+ const term = q.replace(/"/g, "").trim();
149
+ patterns.push(term, term.replace(/\s+/g, "_"));
150
+ });
151
+ }
152
+ else {
153
+ // Extract the action part after block/allow/pause
154
+ const actionPart = lower.replace(/^(block|ban|prevent|prohibit|stop|forbid|disallow|allow|permit|enable|pause|review|flag|hold|limit|no|don.t allow)\s+/i, "").trim();
155
+ if (actionPart) {
156
+ patterns.push(actionPart, actionPart.replace(/\s+/g, "_"));
157
+ }
158
+ }
159
+ }
160
+ // Deduplicate
161
+ const uniquePatterns = [...new Set(patterns)];
162
+ if (uniquePatterns.length === 0)
163
+ return null;
164
+ return {
165
+ id: `custom-rule-${index}`,
166
+ description: line,
167
+ enforcement,
168
+ intent_patterns: uniquePatterns,
169
+ category: "custom",
170
+ };
171
+ }
100
172
  function startInteractiveServer(port, onReady) {
101
173
  const clients = new Set();
102
174
  let isRunning = false;
175
+ // ── Persistent Audit Trail ──
176
+ // Every governance verdict is written to disk as JSONL
177
+ const auditTrail = new auditTrace_1.AuditTrail();
178
+ // Custom guards from plain-English rule editor (session-scoped)
179
+ const customGuards = [];
180
+ let currentSession = {
181
+ id: `session_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
182
+ startedAt: new Date().toISOString(),
183
+ world: "trading",
184
+ guardCount: 0,
185
+ evaluations: [],
186
+ };
187
+ // Session snapshots for multi-run comparison
188
+ const sessionHistory = [];
189
+ function synthesizeSessionReport() {
190
+ // Gather all sessions to report on (history + current if it has data)
191
+ const allSessions = [
192
+ ...sessionHistory,
193
+ ...(currentSession.evaluations.length > 0 ? [currentSession] : []),
194
+ ];
195
+ if (allSessions.length === 0) {
196
+ return {
197
+ sessionId: currentSession.id,
198
+ scenario: "Live governance session (no evaluations yet)",
199
+ runs: [],
200
+ divergence: {
201
+ stabilityTrend: [],
202
+ collapseTrend: [],
203
+ effectivenessTrend: [],
204
+ bestIteration: 0,
205
+ worstIteration: 0,
206
+ totalDivergence: 0,
207
+ narrative: "No evaluations recorded. Send actions to POST /api/evaluate to begin.",
208
+ },
209
+ recommendation: "Start sending agent actions to /api/evaluate to generate governance data.",
210
+ generatedAt: new Date().toISOString(),
211
+ };
212
+ }
213
+ const runs = allSessions.map((sess, idx) => {
214
+ const evals = sess.evaluations;
215
+ const total = evals.length;
216
+ const blocked = evals.filter(e => e.decision === "BLOCK").length;
217
+ const modified = evals.filter(e => e.decision === "MODIFY").length;
218
+ const allowed = evals.filter(e => e.decision === "ALLOW").length;
219
+ // Synthesize metrics from real evaluation data
220
+ const blockRate = total > 0 ? blocked / total : 0;
221
+ const interventionRate = total > 0 ? (blocked + modified) / total : 0;
222
+ // Stability: higher when governance is actively catching harmful actions
223
+ // If nothing is blocked, either the system is clean OR governance is too weak
224
+ const stabilityScore = total > 0
225
+ ? Math.min(0.95, 0.4 + interventionRate * 0.4 + (allowed / Math.max(1, total)) * 0.2)
226
+ : 0.5;
227
+ // Collapse probability: lower when more harmful actions are caught
228
+ const collapseProbability = total > 0
229
+ ? Math.max(0.02, 0.6 - interventionRate * 0.5 - blockRate * 0.15)
230
+ : 0.5;
231
+ // Governance effectiveness: composite of intervention quality
232
+ const governanceEffectiveness = total > 0
233
+ ? Math.min(0.95, interventionRate * 0.6 + blockRate * 0.3 + (total > 10 ? 0.1 : 0))
234
+ : 0;
235
+ // Unique rules that fired
236
+ const triggeredRules = [...new Set(evals.filter(e => e.ruleId).map(e => e.ruleId))];
237
+ // Unique actors and actions
238
+ const uniqueActors = [...new Set(evals.map(e => e.actor))];
239
+ const uniqueActions = [...new Set(evals.map(e => e.action))];
240
+ return {
241
+ iteration: idx + 1,
242
+ worldName: sess.world || "live-session",
243
+ ruleCount: sess.guardCount,
244
+ gateCount: 0,
245
+ metrics: {
246
+ avgImpact: interventionRate > 0 ? -(interventionRate * 0.5) : 0.1,
247
+ collapseProbability,
248
+ stabilityScore,
249
+ coalitionRisks: 0,
250
+ polarizationEvents: 0,
251
+ peakNegativeSentiment: blockRate > 0.3 ? -0.6 : -0.2,
252
+ consensusRounds: 0,
253
+ maxVolatility: blockRate > 0.2 ? 0.7 : 0.3,
254
+ },
255
+ comparison: {
256
+ collapseReduction: collapseProbability < 0.5 ? (0.6 - collapseProbability) : 0,
257
+ stabilityImprovement: stabilityScore > 0.5 ? (stabilityScore - 0.4) : 0,
258
+ volatilityReduction: interventionRate * 0.5,
259
+ coalitionRiskReduction: 0,
260
+ governanceEffectiveness,
261
+ narrative: total > 0
262
+ ? `${total} actions evaluated: ${blocked} blocked, ${modified} modified, ${allowed} allowed across ${uniqueActors.length} agent(s) performing ${uniqueActions.length} action type(s).`
263
+ : "No evaluations in this session.",
264
+ },
265
+ governanceStats: {
266
+ engineLoaded: true,
267
+ totalEvaluations: total,
268
+ verdicts: { allow: allowed, block: blocked, pause: modified },
269
+ rulesFired: triggeredRules.length,
270
+ worldCollapsed: false,
271
+ finalViability: stabilityScore > 0.6 ? "stable" : stabilityScore > 0.4 ? "at-risk" : "critical",
272
+ invariantsChecked: sess.guardCount,
273
+ triggeredGuards: triggeredRules,
274
+ },
275
+ };
276
+ });
277
+ // Compute rule changes between sessions
278
+ for (let i = 1; i < runs.length; i++) {
279
+ const prev = allSessions[i - 1];
280
+ const curr = allSessions[i];
281
+ if (prev.guardCount !== curr.guardCount) {
282
+ runs[i].ruleChanges = {
283
+ added: [],
284
+ removed: [],
285
+ gatesAdded: [],
286
+ gatesRemoved: [],
287
+ thesisChanged: prev.world !== curr.world,
288
+ };
289
+ }
290
+ }
291
+ // Divergence analysis
292
+ const stabilityTrend = runs.map(r => r.metrics.stabilityScore);
293
+ const collapseTrend = runs.map(r => r.metrics.collapseProbability);
294
+ const effectivenessTrend = runs.map(r => r.comparison.governanceEffectiveness);
295
+ let bestIteration = 1;
296
+ let worstIteration = 1;
297
+ let bestScore = -Infinity;
298
+ let worstScore = Infinity;
299
+ for (const run of runs) {
300
+ if (run.comparison.governanceEffectiveness > bestScore) {
301
+ bestScore = run.comparison.governanceEffectiveness;
302
+ bestIteration = run.iteration;
303
+ }
304
+ if (run.comparison.governanceEffectiveness < worstScore) {
305
+ worstScore = run.comparison.governanceEffectiveness;
306
+ worstIteration = run.iteration;
307
+ }
308
+ }
309
+ let totalDivergence = 0;
310
+ for (let i = 1; i < runs.length; i++) {
311
+ totalDivergence += Math.abs(runs[i].metrics.stabilityScore - runs[i - 1].metrics.stabilityScore);
312
+ totalDivergence += Math.abs(runs[i].metrics.collapseProbability - runs[i - 1].metrics.collapseProbability);
313
+ totalDivergence += Math.abs(runs[i].comparison.governanceEffectiveness - runs[i - 1].comparison.governanceEffectiveness);
314
+ }
315
+ // Build narrative
316
+ const totalEvals = allSessions.reduce((sum, s) => sum + s.evaluations.length, 0);
317
+ const totalBlocked = allSessions.reduce((sum, s) => sum + s.evaluations.filter(e => e.decision === "BLOCK").length, 0);
318
+ const narrativeParts = [];
319
+ narrativeParts.push(`${allSessions.length} session(s), ${totalEvals} total evaluations, ${totalBlocked} blocked.`);
320
+ if (runs.length > 1) {
321
+ const stabDelta = stabilityTrend[stabilityTrend.length - 1] - stabilityTrend[0];
322
+ if (stabDelta > 0.05)
323
+ narrativeParts.push(`Stability improved ${(stabDelta * 100).toFixed(0)}pp across sessions.`);
324
+ narrativeParts.push(`Best outcome: session ${bestIteration}.`);
325
+ }
326
+ const best = runs[bestIteration - 1];
327
+ const recommendation = runs.length === 1
328
+ ? `Single session: ${best.governanceStats.totalEvaluations} evaluations, ${best.governanceStats.verdicts.block} blocked. Apply different rules and reset to compare.`
329
+ : `Best session: #${bestIteration} ("${best.worldName}") — ${(best.comparison.governanceEffectiveness * 100).toFixed(0)}% effectiveness, ${(best.metrics.stabilityScore * 100).toFixed(0)}% stability.`;
330
+ return {
331
+ sessionId: currentSession.id,
332
+ scenario: `Live governance — ${currentSession.world}`,
333
+ runs,
334
+ divergence: {
335
+ stabilityTrend,
336
+ collapseTrend,
337
+ effectivenessTrend,
338
+ bestIteration,
339
+ worstIteration,
340
+ totalDivergence: Number(totalDivergence.toFixed(3)),
341
+ narrative: narrativeParts.join(" "),
342
+ },
343
+ recommendation,
344
+ generatedAt: new Date().toISOString(),
345
+ };
346
+ }
103
347
  function broadcast(event) {
104
348
  const data = `data: ${JSON.stringify(event)}\n\n`;
105
349
  for (const client of clients) {
@@ -133,6 +377,18 @@ function startInteractiveServer(port, onReady) {
133
377
  });
134
378
  world.state_variables = updatedVars;
135
379
  }
380
+ // Inject custom rules as first-class invariants (not just guards)
381
+ // This ensures user rules affect BOTH governance paths:
382
+ // 1. Guard engine (intent-level blocking) — already wired via customGuards[]
383
+ // 2. Invariant engine (system-level stability shaping) — wired here
384
+ if (customGuards.length > 0) {
385
+ const customInvariants = customGuards.map(cg => ({
386
+ id: cg.id,
387
+ description: cg.description,
388
+ enforceable: cg.enforcement === "block",
389
+ }));
390
+ world.invariants = [...world.invariants, ...customInvariants];
391
+ }
136
392
  // Resolve narrative events
137
393
  let narrativeEvents = [];
138
394
  if (config.scenarioId && scenarioLibrary_1.SCENARIO_LIBRARY[config.scenarioId]) {
@@ -156,7 +412,12 @@ function startInteractiveServer(port, onReady) {
156
412
  scenario: resolved.scenario,
157
413
  worldThesis: world.thesis,
158
414
  agents: resolved.stakeholders.map(s => s.id),
159
- invariants: world.invariants.map(inv => ({ id: inv.id, description: inv.description })),
415
+ invariants: world.invariants.map(inv => ({
416
+ id: inv.id,
417
+ description: inv.description,
418
+ enforcement: inv.enforceable ? "full" : "advisory",
419
+ source: inv.id.startsWith("custom-rule-") ? "user" : "world",
420
+ })),
160
421
  gates: (world.gates ?? []).map(g => ({ id: g.id, label: g.label, severity: g.severity })),
161
422
  narrativeEvents: narrativeEvents.map(e => ({ id: e.id, headline: e.headline, round: e.round, severity: e.severity })),
162
423
  totalRounds: rounds,
@@ -372,47 +633,170 @@ function startInteractiveServer(port, onReady) {
372
633
  // Without guards, everything defaults to ALLOW. These guards define what
373
634
  // simulation actions should be governed.
374
635
  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" },
636
+ // Detect world type from request — social media worlds get social guards,
637
+ // financial worlds get trading guards, unknown gets both.
638
+ const isSocial = payload.world === "social-media" || payload.world === "social"
639
+ || ["create_post", "like_post", "repost", "follow", "unfollow", "create_comment",
640
+ "search_posts", "mute", "unmute", "trend"].includes(payload.action);
641
+ const financialGuards = [
642
+ {
643
+ id: "sim-panic-actions",
644
+ label: "Block panic-driven actions",
645
+ description: "Prevents panic selling, aggressive shorting, and other destabilizing actions during high volatility",
646
+ category: "structural",
647
+ enforcement: "block",
648
+ immutable: true,
649
+ invariant_ref: nvWorld.invariants[0]?.id,
650
+ intent_patterns: ["panic_sell", "panic sell", "panic buy", "panic_buy"],
651
+ default_enabled: true,
414
652
  },
415
- };
653
+ {
654
+ id: "sim-excessive-leverage",
655
+ label: "Block excessive leverage",
656
+ description: "Prevents increasing leverage positions that could amplify cascades",
657
+ category: "structural",
658
+ enforcement: "block",
659
+ immutable: true,
660
+ intent_patterns: ["increase_leverage", "increase leverage", "max leverage"],
661
+ default_enabled: true,
662
+ },
663
+ {
664
+ id: "sim-aggressive-actions",
665
+ label: "Pause aggressive market actions",
666
+ description: "Requires review for aggressive buying or shorting that could move markets",
667
+ category: "operational",
668
+ enforcement: "pause",
669
+ immutable: false,
670
+ intent_patterns: ["aggressive_buy", "aggressive buy", "aggressive_short", "short"],
671
+ default_enabled: true,
672
+ },
673
+ ];
674
+ // Social media guards for MiroFish/OASIS agent actions.
675
+ // These govern what AI agents can do on simulated social platforms.
676
+ const socialGuards = [
677
+ {
678
+ id: "social-harmful-content",
679
+ label: "Block harmful content creation",
680
+ description: "Prevents agents from posting content that incites panic, spreads disinformation, or promotes harmful behavior",
681
+ category: "structural",
682
+ enforcement: "block",
683
+ immutable: true,
684
+ intent_patterns: ["create_post", "create_comment", "quote_post"],
685
+ content_patterns: ["panic", "crash", "collaps", "sell everything", "market is dead", "scam", "rug pull", "ponzi"],
686
+ default_enabled: true,
687
+ },
688
+ {
689
+ id: "social-coordinated-manipulation",
690
+ label: "Block coordinated manipulation",
691
+ description: "Prevents agents from engaging in coordinated inauthentic behavior like mass following, mass liking, or brigading",
692
+ category: "structural",
693
+ enforcement: "block",
694
+ immutable: true,
695
+ intent_patterns: ["follow", "like_post", "repost"],
696
+ default_enabled: true,
697
+ },
698
+ {
699
+ id: "social-spam-prevention",
700
+ label: "Pause high-frequency posting",
701
+ description: "Rate-limits agents that post too frequently, preventing spam and platform flooding",
702
+ category: "operational",
703
+ enforcement: "pause",
704
+ immutable: false,
705
+ intent_patterns: ["create_post", "create_comment", "repost", "quote_post"],
706
+ default_enabled: true,
707
+ },
708
+ {
709
+ id: "social-engagement-farming",
710
+ label: "Block engagement farming",
711
+ description: "Prevents agents from like-bombing, follow-unfollowing, or other engagement manipulation tactics",
712
+ category: "operational",
713
+ enforcement: "block",
714
+ immutable: false,
715
+ intent_patterns: ["like_post", "unlike_post", "follow", "unfollow"],
716
+ default_enabled: true,
717
+ },
718
+ ];
719
+ const guards = isSocial ? socialGuards : financialGuards;
720
+ // Build intent vocabulary from selected guards
721
+ const intentVocabulary = {};
722
+ for (const g of guards) {
723
+ for (const pat of g.intent_patterns) {
724
+ if (!intentVocabulary[pat]) {
725
+ intentVocabulary[pat] = { label: pat.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase()), pattern: pat };
726
+ }
727
+ }
728
+ }
729
+ nvWorld.guards = { guards, intent_vocabulary: intentVocabulary };
730
+ }
731
+ // Inject custom guards from plain-English rule editor
732
+ if (customGuards.length > 0 && nvWorld.guards) {
733
+ const existingGuards = nvWorld.guards.guards;
734
+ for (const cg of customGuards) {
735
+ existingGuards.push({
736
+ id: cg.id,
737
+ label: cg.label,
738
+ description: cg.description,
739
+ category: cg.category,
740
+ enforcement: cg.enforcement,
741
+ immutable: false,
742
+ intent_patterns: cg.intent_patterns,
743
+ default_enabled: true,
744
+ });
745
+ // Add patterns to vocabulary
746
+ for (const pat of cg.intent_patterns) {
747
+ nvWorld.guards.intent_vocabulary[pat] = { label: pat, pattern: pat };
748
+ }
749
+ }
750
+ }
751
+ // Content-aware governance for social media actions.
752
+ // The guard engine matches intent against intent_patterns, but for social media
753
+ // we also need to check message content for harmful patterns.
754
+ const messageContent = typeof payload.payload?.message === "string"
755
+ ? payload.payload.message.toLowerCase()
756
+ : typeof payload.payload?.content === "string"
757
+ ? payload.payload.content.toLowerCase()
758
+ : "";
759
+ const contentPatterns = ["panic", "crash", "collaps", "sell everything", "market is dead",
760
+ "scam", "rug pull", "ponzi", "buy now or lose", "guaranteed returns", "get rich"];
761
+ const hasHarmfulContent = messageContent.length > 0
762
+ && contentPatterns.some(p => messageContent.includes(p));
763
+ // If content is harmful and action is content-creating, short-circuit to BLOCK
764
+ const isContentAction = ["create_post", "create_comment", "quote_post", "repost"].includes(payload.action);
765
+ if (hasHarmfulContent && isContentAction) {
766
+ const matchedPattern = contentPatterns.find(p => messageContent.includes(p)) ?? "harmful content";
767
+ broadcast({
768
+ type: "round",
769
+ round: 0,
770
+ totalRounds: 0,
771
+ phase: "governed",
772
+ reactions: [{
773
+ stakeholder_id: payload.actor,
774
+ reaction: payload.action,
775
+ impact: 0,
776
+ confidence: 0.9,
777
+ trigger: "bridge",
778
+ verdict: { status: "BLOCK", reason: `Content violates governance: "${matchedPattern}" detected`, ruleId: "social-harmful-content" },
779
+ }],
780
+ avgImpact: 0,
781
+ maxVolatility: 0,
782
+ dynamics: [],
783
+ interventionCount: 1,
784
+ });
785
+ // Record in session
786
+ currentSession.evaluations.push({
787
+ actor: payload.actor, action: payload.action, decision: "BLOCK",
788
+ reason: `Content blocked: "${matchedPattern}" detected`,
789
+ ruleId: "social-harmful-content", world: payload.world ?? currentSession.world,
790
+ timestamp: Date.now(), payload: payload.payload,
791
+ });
792
+ jsonResponse(res, 200, {
793
+ decision: "BLOCK",
794
+ reason: `Content blocked: "${matchedPattern}" detected in ${payload.action} — violates social media governance policy`,
795
+ rule_id: "social-harmful-content",
796
+ evidence: { matched_pattern: matchedPattern, action: payload.action, actor: payload.actor },
797
+ modified_action: null,
798
+ });
799
+ return;
416
800
  }
417
801
  // Build a proper GuardEvent — the guard engine matches intent against intent_patterns.
418
802
  // Omit `direction` — setting it enables execution-intent safety checks (prompt injection
@@ -423,7 +807,7 @@ function startInteractiveServer(port, onReady) {
423
807
  tool: "simulation",
424
808
  scope: `bridge/${payload.actor}`,
425
809
  actionCategory: "execute",
426
- riskLevel: (["panic_sell", "panic_buy", "increase_leverage"].includes(payload.action) ? "high" : "medium"),
810
+ riskLevel: (["panic_sell", "panic_buy", "increase_leverage", "create_post", "repost", "quote_post"].includes(payload.action) ? "high" : "medium"),
427
811
  args: {
428
812
  actor: payload.actor,
429
813
  action: payload.action,
@@ -469,6 +853,31 @@ function startInteractiveServer(port, onReady) {
469
853
  dynamics: [],
470
854
  interventionCount: decision !== "ALLOW" ? 1 : 0,
471
855
  });
856
+ // Record in session
857
+ currentSession.evaluations.push({
858
+ actor: payload.actor, action: payload.action,
859
+ decision: decision,
860
+ reason: verdict.reason ?? "", ruleId: verdict.ruleId ?? null,
861
+ world: payload.world ?? currentSession.world,
862
+ timestamp: Date.now(), payload: payload.payload,
863
+ });
864
+ currentSession.guardCount = customGuards.length + (nvWorld.guards?.guards?.length ?? 0);
865
+ // Persist to audit trail on disk
866
+ auditTrail.logVerdict({
867
+ agent: payload.actor,
868
+ action: payload.action,
869
+ actionType: payload.payload?.type ?? "unknown",
870
+ verdict: decision,
871
+ reason: verdict.reason ?? "",
872
+ confidence: verdict.confidence ?? 0.5,
873
+ rulesFired: verdict.ruleId ? [{
874
+ id: verdict.ruleId,
875
+ description: verdict.reason ?? "",
876
+ effect: decision === "BLOCK" ? "blocked" : decision === "MODIFY" ? "dampened" : "monitored",
877
+ impactReduction: decision === "BLOCK" ? 1 : decision === "MODIFY" ? 0.5 : 0,
878
+ }] : [],
879
+ worldState: payload.world ?? currentSession.world,
880
+ });
472
881
  jsonResponse(res, 200, {
473
882
  decision,
474
883
  reason: verdict.reason ?? null,
@@ -478,6 +887,12 @@ function startInteractiveServer(port, onReady) {
478
887
  });
479
888
  }
480
889
  catch (err) {
890
+ // Record fail-open in session (payload may be out of scope if JSON parse failed)
891
+ currentSession.evaluations.push({
892
+ actor: "unknown", action: "unknown",
893
+ decision: "ALLOW", reason: "Governance evaluation error — fail open",
894
+ ruleId: null, world: currentSession.world, timestamp: Date.now(),
895
+ });
481
896
  // Fail open — return ALLOW on any error
482
897
  jsonResponse(res, 200, {
483
898
  decision: "ALLOW",
@@ -489,6 +904,395 @@ function startInteractiveServer(port, onReady) {
489
904
  }
490
905
  return;
491
906
  }
907
+ // ── Plain-English Rule Parser ──
908
+ // Parses natural language rules into guard definitions (local, no LLM needed)
909
+ if (req.url === "/api/parse-rules" && req.method === "POST") {
910
+ try {
911
+ const body = await readBody(req);
912
+ const payload = JSON.parse(body);
913
+ if (!payload.text) {
914
+ jsonResponse(res, 400, { error: "text is required" });
915
+ return;
916
+ }
917
+ const lines = payload.text.split("\n").map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith("#"));
918
+ const rules = lines.map((line, i) => parseNaturalLanguageRule(line, i));
919
+ jsonResponse(res, 200, { rules: rules.filter(Boolean), raw: lines });
920
+ }
921
+ catch (err) {
922
+ jsonResponse(res, 400, { error: "Invalid request" });
923
+ }
924
+ return;
925
+ }
926
+ // Apply parsed rules to the active governance context
927
+ if (req.url === "/api/apply-rules" && req.method === "POST") {
928
+ try {
929
+ const body = await readBody(req);
930
+ const payload = JSON.parse(body);
931
+ if (!payload.rules || !Array.isArray(payload.rules)) {
932
+ jsonResponse(res, 400, { error: "rules array is required" });
933
+ return;
934
+ }
935
+ // Store custom rules in memory for the session
936
+ customGuards.length = 0;
937
+ for (const rule of payload.rules) {
938
+ customGuards.push({
939
+ id: rule.id,
940
+ label: rule.description,
941
+ description: rule.description,
942
+ category: "custom",
943
+ enforcement: rule.enforcement,
944
+ immutable: false,
945
+ intent_patterns: rule.intent_patterns,
946
+ default_enabled: true,
947
+ });
948
+ }
949
+ jsonResponse(res, 200, {
950
+ status: "applied",
951
+ applied: customGuards.length,
952
+ enforcement: "full",
953
+ detail: "Rules enforced across guard engine (intent blocking) AND invariant engine (system dynamics)",
954
+ });
955
+ }
956
+ catch (err) {
957
+ jsonResponse(res, 400, { error: "Invalid request" });
958
+ }
959
+ return;
960
+ }
961
+ // ── Clear Rules ──
962
+ // Wipes all custom guards and resets governance to base world rules
963
+ if (req.url === "/api/clear-rules" && req.method === "POST") {
964
+ customGuards.length = 0;
965
+ jsonResponse(res, 200, {
966
+ status: "cleared",
967
+ message: "All custom rules removed. Governance reset to base world rules.",
968
+ });
969
+ return;
970
+ }
971
+ // ── Load World File ──
972
+ // Accept a full world definition JSON and use it as the active world
973
+ if (req.url === "/api/load-world-file" && req.method === "POST") {
974
+ try {
975
+ const body = await readBody(req);
976
+ const payload = JSON.parse(body);
977
+ if (!payload.world) {
978
+ jsonResponse(res, 400, { error: "world object is required" });
979
+ return;
980
+ }
981
+ const w = payload.world;
982
+ // Parse any plain-English rules in the world file into guards
983
+ customGuards.length = 0;
984
+ if (w.rules && Array.isArray(w.rules)) {
985
+ for (let i = 0; i < w.rules.length; i++) {
986
+ const rule = w.rules[i];
987
+ if (rule.intent_patterns && rule.intent_patterns.length > 0) {
988
+ // Already structured rule
989
+ customGuards.push({
990
+ id: rule.id || `world-rule-${i}`,
991
+ label: rule.description,
992
+ description: rule.description,
993
+ category: "world-file",
994
+ enforcement: rule.enforcement || "block",
995
+ immutable: false,
996
+ intent_patterns: rule.intent_patterns,
997
+ default_enabled: true,
998
+ });
999
+ }
1000
+ else {
1001
+ // Plain-English rule — parse it
1002
+ const parsed = parseNaturalLanguageRule(rule.description, i);
1003
+ if (parsed) {
1004
+ customGuards.push({
1005
+ id: parsed.id,
1006
+ label: parsed.description,
1007
+ description: parsed.description,
1008
+ category: "world-file",
1009
+ enforcement: parsed.enforcement,
1010
+ immutable: false,
1011
+ intent_patterns: parsed.intent_patterns,
1012
+ default_enabled: true,
1013
+ });
1014
+ }
1015
+ }
1016
+ }
1017
+ }
1018
+ // Build the response world definition for the UI
1019
+ const loadedWorld = {
1020
+ id: "custom-world",
1021
+ title: w.name || "Custom World",
1022
+ thesis: w.thesis || "User-defined world",
1023
+ stateVariables: w.state_variables || [],
1024
+ invariants: (w.invariants || []).map(inv => ({
1025
+ id: inv.id,
1026
+ description: inv.description,
1027
+ enforceable: inv.enforceable !== false,
1028
+ })),
1029
+ gates: (w.gates || []).map(g => ({
1030
+ id: g.id,
1031
+ label: g.label,
1032
+ condition: g.condition,
1033
+ severity: g.severity || "warning",
1034
+ })),
1035
+ rulesApplied: customGuards.length,
1036
+ };
1037
+ jsonResponse(res, 200, {
1038
+ status: "loaded",
1039
+ world: loadedWorld,
1040
+ rulesApplied: customGuards.length,
1041
+ message: `World "${loadedWorld.title}" loaded with ${loadedWorld.invariants.length} invariants, ${loadedWorld.gates.length} gates, and ${customGuards.length} rules.`,
1042
+ });
1043
+ }
1044
+ catch (err) {
1045
+ jsonResponse(res, 400, { error: "Invalid world file JSON" });
1046
+ }
1047
+ return;
1048
+ }
1049
+ // ── Export World File ──
1050
+ // Exports the current world configuration (base world + custom rules + overrides) as a world file
1051
+ if (req.url === "/api/export-world" && req.method === "GET") {
1052
+ const { resolveWorld } = await Promise.resolve().then(() => __importStar(require("./worldComparison")));
1053
+ const worldId = currentSession.world || "trading";
1054
+ let baseWorld;
1055
+ try {
1056
+ baseWorld = resolveWorld(worldId);
1057
+ }
1058
+ catch {
1059
+ baseWorld = null;
1060
+ }
1061
+ const exportedWorld = {
1062
+ name: baseWorld?.title || worldId,
1063
+ thesis: baseWorld?.world?.thesis || "Exported world",
1064
+ state_variables: baseWorld?.world?.state_variables || [],
1065
+ invariants: baseWorld?.world?.invariants || [],
1066
+ gates: baseWorld?.world?.gates || [],
1067
+ rules: customGuards.map(g => ({
1068
+ id: g.id,
1069
+ description: g.description,
1070
+ enforcement: g.enforcement,
1071
+ intent_patterns: g.intent_patterns,
1072
+ })),
1073
+ };
1074
+ res.writeHead(200, {
1075
+ "Content-Type": "application/json",
1076
+ "Content-Disposition": `attachment; filename="${worldId}-world.json"`,
1077
+ "Access-Control-Allow-Origin": "*",
1078
+ });
1079
+ res.end(JSON.stringify({ world: exportedWorld }, null, 2));
1080
+ return;
1081
+ }
1082
+ // ── Session Reporting Endpoints ──
1083
+ // Connect the serve runtime to the enforce reporting pipeline.
1084
+ // Users can request reports, stats, and recommendations from live governance data.
1085
+ // GET /api/session — current session stats (lightweight)
1086
+ if (req.url === "/api/session" && req.method === "GET") {
1087
+ const evals = currentSession.evaluations;
1088
+ const blocked = evals.filter(e => e.decision === "BLOCK").length;
1089
+ const modified = evals.filter(e => e.decision === "MODIFY").length;
1090
+ const allowed = evals.filter(e => e.decision === "ALLOW").length;
1091
+ const uniqueActors = [...new Set(evals.map(e => e.actor))];
1092
+ const uniqueActions = [...new Set(evals.map(e => e.action))];
1093
+ const triggeredRules = [...new Set(evals.filter(e => e.ruleId).map(e => e.ruleId))];
1094
+ jsonResponse(res, 200, {
1095
+ sessionId: currentSession.id,
1096
+ startedAt: currentSession.startedAt,
1097
+ world: currentSession.world,
1098
+ guardCount: currentSession.guardCount,
1099
+ evaluations: {
1100
+ total: evals.length,
1101
+ blocked,
1102
+ modified,
1103
+ allowed,
1104
+ },
1105
+ agents: uniqueActors,
1106
+ actionTypes: uniqueActions,
1107
+ triggeredRules,
1108
+ historyCount: sessionHistory.length,
1109
+ });
1110
+ return;
1111
+ }
1112
+ // GET /api/session/report — full enforcement report (text)
1113
+ if (req.url === "/api/session/report" && req.method === "GET") {
1114
+ const report = synthesizeSessionReport();
1115
+ // Format as human-readable text using the same style as enforce CLI
1116
+ const lines = [];
1117
+ lines.push("");
1118
+ lines.push(" LIVE GOVERNANCE — ENFORCEMENT REPORT");
1119
+ lines.push(" " + "=".repeat(70));
1120
+ lines.push(` Session: ${report.sessionId}`);
1121
+ lines.push(` Scenario: ${report.scenario}`);
1122
+ lines.push(` Sessions: ${report.runs.length}`);
1123
+ lines.push(` Generated: ${report.generatedAt}`);
1124
+ lines.push("");
1125
+ if (report.runs.length > 0) {
1126
+ lines.push(" RUN HISTORY");
1127
+ lines.push(" " + "-".repeat(70));
1128
+ lines.push(` ${"#".padEnd(4)} ${"World".padEnd(25)} ${"Rules".padEnd(8)} ${"Evals".padEnd(8)} ${"Blocked".padEnd(10)} ${"Effectiveness"}`);
1129
+ lines.push(" " + "-".repeat(70));
1130
+ for (const run of report.runs) {
1131
+ const name = run.worldName.length > 23 ? run.worldName.slice(0, 22) + "…" : run.worldName;
1132
+ lines.push(` ${String(run.iteration).padEnd(4)} ${name.padEnd(25)} ${String(run.ruleCount).padEnd(8)} ${String(run.governanceStats.totalEvaluations).padEnd(8)} ${String(run.governanceStats.verdicts.block).padEnd(10)} ${(run.comparison.governanceEffectiveness * 100).toFixed(0)}%`);
1133
+ }
1134
+ lines.push("");
1135
+ lines.push(" DIVERGENCE ANALYSIS");
1136
+ lines.push(" " + "-".repeat(70));
1137
+ lines.push(` Stability trend: ${report.divergence.stabilityTrend.map(s => `${(s * 100).toFixed(0)}%`).join(" → ")}`);
1138
+ lines.push(` Collapse trend: ${report.divergence.collapseTrend.map(s => `${(s * 100).toFixed(0)}%`).join(" → ")}`);
1139
+ lines.push(` Effectiveness trend: ${report.divergence.effectivenessTrend.map(s => `${(s * 100).toFixed(0)}%`).join(" → ")}`);
1140
+ lines.push(` Total divergence: ${report.divergence.totalDivergence}`);
1141
+ lines.push("");
1142
+ lines.push(` ${report.divergence.narrative}`);
1143
+ // Blocked action breakdown
1144
+ const allEvals = [
1145
+ ...sessionHistory.flatMap(s => s.evaluations),
1146
+ ...currentSession.evaluations,
1147
+ ];
1148
+ const blockedEvals = allEvals.filter(e => e.decision === "BLOCK");
1149
+ if (blockedEvals.length > 0) {
1150
+ lines.push("");
1151
+ lines.push(" BLOCKED ACTIONS");
1152
+ lines.push(" " + "-".repeat(70));
1153
+ // Group by rule
1154
+ const byRule = {};
1155
+ for (const e of blockedEvals) {
1156
+ const key = e.ruleId ?? "unknown";
1157
+ if (!byRule[key])
1158
+ byRule[key] = { count: 0, actors: new Set(), actions: new Set() };
1159
+ byRule[key].count++;
1160
+ byRule[key].actors.add(e.actor);
1161
+ byRule[key].actions.add(e.action);
1162
+ }
1163
+ const sorted = Object.entries(byRule).sort((a, b) => b[1].count - a[1].count);
1164
+ for (const [ruleId, data] of sorted) {
1165
+ lines.push(` ${ruleId}: ${data.count} blocks across ${data.actors.size} agent(s)`);
1166
+ lines.push(` Actions: ${[...data.actions].join(", ")}`);
1167
+ }
1168
+ }
1169
+ // Recommendations (deterministic, no LLM needed)
1170
+ lines.push("");
1171
+ lines.push(" RECOMMENDATIONS");
1172
+ lines.push(" " + "-".repeat(70));
1173
+ const best = report.runs[report.divergence.bestIteration - 1];
1174
+ if (best) {
1175
+ const totalEvals = best.governanceStats.totalEvaluations;
1176
+ const blockRate = totalEvals > 0 ? best.governanceStats.verdicts.block / totalEvals : 0;
1177
+ if (blockRate > 0.5) {
1178
+ lines.push(" HIGH BLOCK RATE — Rules may be too aggressive.");
1179
+ lines.push(" Try relaxing constraints or adding MODIFY actions instead of hard blocks.");
1180
+ lines.push(` Current block rate: ${(blockRate * 100).toFixed(0)}% (${best.governanceStats.verdicts.block}/${totalEvals})`);
1181
+ }
1182
+ else if (blockRate < 0.05 && totalEvals > 20) {
1183
+ lines.push(" LOW BLOCK RATE — Rules may be too permissive.");
1184
+ lines.push(" Try adding content-specific guards or lowering thresholds.");
1185
+ lines.push(` Current block rate: ${(blockRate * 100).toFixed(0)}% (${best.governanceStats.verdicts.block}/${totalEvals})`);
1186
+ }
1187
+ else if (blockRate > 0) {
1188
+ lines.push(` Governance is active: ${(blockRate * 100).toFixed(0)}% of actions blocked.`);
1189
+ lines.push(" To iterate: apply different rules via POST /api/apply-rules, then POST /api/session/reset.");
1190
+ lines.push(" This creates a new session for side-by-side comparison.");
1191
+ }
1192
+ if (report.runs.length === 1) {
1193
+ lines.push("");
1194
+ lines.push(" EXPERIMENT: Apply different rules and reset to compare sessions.");
1195
+ lines.push(" This will show divergence: how different governance changes outcomes.");
1196
+ }
1197
+ }
1198
+ lines.push("");
1199
+ lines.push(" RECOMMENDATION");
1200
+ lines.push(" " + "-".repeat(70));
1201
+ lines.push(` ${report.recommendation}`);
1202
+ }
1203
+ lines.push("");
1204
+ lines.push(" " + "=".repeat(70));
1205
+ lines.push(" NeuroVerse Policy Enforcement System — Live Governance");
1206
+ lines.push(" Design rules. Run reality. See what changes.");
1207
+ lines.push(" " + "=".repeat(70));
1208
+ lines.push("");
1209
+ res.writeHead(200, { "Content-Type": "text/plain", "Access-Control-Allow-Origin": "*" });
1210
+ res.end(lines.join("\n"));
1211
+ return;
1212
+ }
1213
+ // GET /api/session/report.json — full enforcement report (JSON)
1214
+ if (req.url === "/api/session/report.json" && req.method === "GET") {
1215
+ const report = synthesizeSessionReport();
1216
+ jsonResponse(res, 200, report);
1217
+ return;
1218
+ }
1219
+ // GET /api/session/evaluations — raw evaluation log
1220
+ if (req.url === "/api/session/evaluations" && req.method === "GET") {
1221
+ jsonResponse(res, 200, {
1222
+ sessionId: currentSession.id,
1223
+ count: currentSession.evaluations.length,
1224
+ evaluations: currentSession.evaluations.slice(-200), // last 200
1225
+ });
1226
+ return;
1227
+ }
1228
+ // POST /api/session/reset — snapshot current session and start fresh
1229
+ // This is how users create multi-run comparisons:
1230
+ // 1. Apply rules A → run simulation → POST /api/session/reset
1231
+ // 2. Apply rules B → run simulation → GET /api/session/report
1232
+ // Now the report shows divergence between rules A and rules B.
1233
+ if (req.url === "/api/session/reset" && req.method === "POST") {
1234
+ if (currentSession.evaluations.length > 0) {
1235
+ sessionHistory.push({ ...currentSession, evaluations: [...currentSession.evaluations] });
1236
+ }
1237
+ const body = await readBody(req).catch(() => "{}");
1238
+ const opts = JSON.parse(body || "{}");
1239
+ currentSession = {
1240
+ id: `session_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
1241
+ startedAt: new Date().toISOString(),
1242
+ world: opts.world ?? currentSession.world,
1243
+ guardCount: customGuards.length,
1244
+ evaluations: [],
1245
+ };
1246
+ jsonResponse(res, 200, {
1247
+ status: "reset",
1248
+ newSessionId: currentSession.id,
1249
+ previousSessions: sessionHistory.length,
1250
+ message: `Session reset. ${sessionHistory.length} session(s) in history. Apply new rules and evaluate to compare.`,
1251
+ });
1252
+ return;
1253
+ }
1254
+ // POST /api/session/save — save session as experiment with lineage
1255
+ if (req.url === "/api/session/save" && req.method === "POST") {
1256
+ const report = synthesizeSessionReport();
1257
+ if (report.runs.length === 0) {
1258
+ jsonResponse(res, 400, { error: "No evaluations to save. Send actions to /api/evaluate first." });
1259
+ return;
1260
+ }
1261
+ const experiment = {
1262
+ id: `exp-live-${Date.now().toString(36)}`,
1263
+ savedAt: new Date().toISOString(),
1264
+ scenario: report.scenario,
1265
+ source: "live-governance",
1266
+ sessions: report.runs.length,
1267
+ totalEvaluations: report.runs.reduce((sum, r) => sum + r.governanceStats.totalEvaluations, 0),
1268
+ metrics: {
1269
+ stability: report.runs[report.divergence.bestIteration - 1]?.metrics.stabilityScore ?? 0,
1270
+ effectiveness: report.runs[report.divergence.bestIteration - 1]?.comparison.governanceEffectiveness ?? 0,
1271
+ collapseProbability: report.runs[report.divergence.bestIteration - 1]?.metrics.collapseProbability ?? 0,
1272
+ },
1273
+ divergence: report.divergence,
1274
+ recommendation: report.recommendation,
1275
+ report,
1276
+ };
1277
+ // Save to experiments/ directory
1278
+ try {
1279
+ if (!fs.existsSync("experiments"))
1280
+ fs.mkdirSync("experiments", { recursive: true });
1281
+ const filePath = path.join("experiments", `${experiment.id}.json`);
1282
+ fs.writeFileSync(filePath, JSON.stringify(experiment, null, 2));
1283
+ jsonResponse(res, 200, {
1284
+ status: "saved",
1285
+ experimentId: experiment.id,
1286
+ filePath,
1287
+ metrics: experiment.metrics,
1288
+ message: `Saved to ${filePath}. View with: npx nv-sim enforce --load ${filePath}`,
1289
+ });
1290
+ }
1291
+ catch (err) {
1292
+ jsonResponse(res, 500, { error: "Failed to save experiment", detail: String(err) });
1293
+ }
1294
+ return;
1295
+ }
492
1296
  // List available live adapters
493
1297
  if (req.url === "/api/adapters" && req.method === "GET") {
494
1298
  const adapters = Object.values(liveAdapter_1.ADAPTER_REGISTRY).map(a => ({
@@ -616,6 +1420,81 @@ function startInteractiveServer(port, onReady) {
616
1420
  }
617
1421
  return;
618
1422
  }
1423
+ // ── AUDIT TRAIL ENDPOINTS ──
1424
+ // GET /api/audit — summary of current session's audit trail
1425
+ if (req.url === "/api/audit" && req.method === "GET") {
1426
+ jsonResponse(res, 200, auditTrail.summary());
1427
+ return;
1428
+ }
1429
+ // GET /api/audit/entries — all entries from current session (with optional filters)
1430
+ if (req.url?.startsWith("/api/audit/entries") && req.method === "GET") {
1431
+ const url = new URL(req.url, `http://localhost:${port}`);
1432
+ const entries = auditTrail.query({
1433
+ type: url.searchParams.get("type") ?? undefined,
1434
+ agent: url.searchParams.get("agent") ?? undefined,
1435
+ verdict: url.searchParams.get("verdict") ?? undefined,
1436
+ runId: url.searchParams.get("runId") ?? undefined,
1437
+ after: url.searchParams.get("after") ?? undefined,
1438
+ before: url.searchParams.get("before") ?? undefined,
1439
+ });
1440
+ jsonResponse(res, 200, {
1441
+ sessionId: auditTrail.getSessionId(),
1442
+ count: entries.length,
1443
+ entries,
1444
+ });
1445
+ return;
1446
+ }
1447
+ // GET /api/audit/entries/text — human-readable audit trail
1448
+ if (req.url?.startsWith("/api/audit/entries/text") && req.method === "GET") {
1449
+ const entries = auditTrail.readAll();
1450
+ res.writeHead(200, { "Content-Type": "text/plain", "Access-Control-Allow-Origin": "*" });
1451
+ res.end((0, auditTrace_1.formatAuditTrail)(entries, { verbose: true }));
1452
+ return;
1453
+ }
1454
+ // GET /api/audit/sessions — list all audit sessions on disk
1455
+ if (req.url === "/api/audit/sessions" && req.method === "GET") {
1456
+ const sessions = (0, auditTrace_1.listAuditSessions)();
1457
+ jsonResponse(res, 200, {
1458
+ sessions: sessions.map((id) => {
1459
+ const trail = (0, auditTrace_1.loadAuditSession)(id);
1460
+ return trail.summary();
1461
+ }),
1462
+ });
1463
+ return;
1464
+ }
1465
+ // GET /api/audit/sessions/:id — load a specific session's audit trail
1466
+ const auditSessionMatch = req.url?.match(/^\/api\/audit\/sessions\/([^/]+)$/);
1467
+ if (auditSessionMatch && req.method === "GET") {
1468
+ const sessionId = decodeURIComponent(auditSessionMatch[1]);
1469
+ const trail = (0, auditTrace_1.loadAuditSession)(sessionId);
1470
+ const entries = trail.readAll();
1471
+ if (entries.length === 0) {
1472
+ jsonResponse(res, 404, { error: `Audit session "${sessionId}" not found or empty` });
1473
+ }
1474
+ else {
1475
+ jsonResponse(res, 200, {
1476
+ ...trail.summary(),
1477
+ entries,
1478
+ });
1479
+ }
1480
+ return;
1481
+ }
1482
+ // GET /api/audit/search — search across ALL sessions
1483
+ if (req.url?.startsWith("/api/audit/search") && req.method === "GET") {
1484
+ const url = new URL(req.url, `http://localhost:${port}`);
1485
+ const results = (0, auditTrace_1.searchAuditTrails)({
1486
+ type: url.searchParams.get("type") ?? undefined,
1487
+ agent: url.searchParams.get("agent") ?? undefined,
1488
+ verdict: url.searchParams.get("verdict") ?? undefined,
1489
+ after: url.searchParams.get("after") ?? undefined,
1490
+ before: url.searchParams.get("before") ?? undefined,
1491
+ });
1492
+ jsonResponse(res, 200, {
1493
+ count: results.length,
1494
+ entries: results,
1495
+ });
1496
+ return;
1497
+ }
619
1498
  // Serve the interactive dashboard
620
1499
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
621
1500
  res.end(INTERACTIVE_DASHBOARD_HTML);
@@ -632,251 +1511,519 @@ const INTERACTIVE_DASHBOARD_HTML = `<!DOCTYPE html>
632
1511
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
633
1512
  <title>NV-SIM — Scenario Control Platform</title>
634
1513
  <style>
1514
+ /* ── Theme Variables ── */
1515
+ :root {
1516
+ --bg-primary: #0f0f0f;
1517
+ --bg-secondary: #141414;
1518
+ --bg-surface: #1a1a1a;
1519
+ --bg-elevated: #222;
1520
+ --border: #2a2a2a;
1521
+ --border-subtle: #333;
1522
+ --text-primary: #f0f0f0;
1523
+ --text-secondary: #b0b0b0;
1524
+ --text-muted: #888;
1525
+ --text-faint: #666;
1526
+ --accent: #818cf8;
1527
+ --accent-bg: #1e1e3a;
1528
+ --green: #4ade80;
1529
+ --green-bg: #0a2a14;
1530
+ --red: #f87171;
1531
+ --red-bg: #2d0a0a;
1532
+ --yellow: #fbbf24;
1533
+ --yellow-bg: #2d2006;
1534
+ --blue: #60a5fa;
1535
+ --blue-bg: #1e293b;
1536
+ --purple: #a78bfa;
1537
+ }
1538
+ body.light {
1539
+ --bg-primary: #f5f5f5;
1540
+ --bg-secondary: #eaeaea;
1541
+ --bg-surface: #e0e0e0;
1542
+ --bg-elevated: #d4d4d4;
1543
+ --border: #c0c0c0;
1544
+ --border-subtle: #b0b0b0;
1545
+ --text-primary: #1a1a1a;
1546
+ --text-secondary: #444;
1547
+ --text-muted: #666;
1548
+ --text-faint: #888;
1549
+ --accent: #6366f1;
1550
+ --accent-bg: #e8e8ff;
1551
+ --green: #16a34a;
1552
+ --green-bg: #dcfce7;
1553
+ --red: #dc2626;
1554
+ --red-bg: #fee2e2;
1555
+ --yellow: #ca8a04;
1556
+ --yellow-bg: #fef9c3;
1557
+ --blue: #2563eb;
1558
+ --blue-bg: #dbeafe;
1559
+ --purple: #7c3aed;
1560
+ }
1561
+
635
1562
  * { 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; }
1563
+ body { font-family: 'SF Mono', 'Fira Code', monospace; background: var(--bg-primary); color: var(--text-primary); overflow: hidden; transition: background 0.3s, color 0.3s; }
1564
+ .header { padding: 12px 20px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; }
1565
+ .header h1 { font-size: 15px; color: var(--text-primary); }
1566
+ .header .sub { font-size: 11px; color: var(--text-muted); margin-left: 12px; }
1567
+ .header-right { display: flex; align-items: center; gap: 12px; }
1568
+ .theme-toggle { background: var(--bg-surface); border: 1px solid var(--border); color: var(--text-secondary); padding: 4px 10px; border-radius: 6px; font-family: inherit; font-size: 11px; cursor: pointer; transition: all 0.2s; }
1569
+ .theme-toggle:hover { border-color: var(--accent); color: var(--accent); }
640
1570
  .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; }
1571
+ .status.idle { background: var(--accent-bg); color: var(--accent); }
1572
+ .status.live { background: var(--green-bg); color: var(--green); animation: pulse 2s infinite; }
1573
+ .status.complete { background: var(--accent-bg); color: var(--accent); }
644
1574
  @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
645
1575
 
646
1576
  .layout { display: grid; grid-template-columns: 340px 1fr; height: calc(100vh - 49px); }
647
1577
 
648
1578
  /* LEFT PANEL — Controls */
649
- .controls { background: #0d0d0d; border-right: 1px solid #1a1a1a; overflow-y: auto; padding: 16px; }
1579
+ .controls { background: var(--bg-secondary); border-right: 1px solid var(--border); overflow-y: auto; padding: 16px; }
650
1580
  .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; }
1581
+ .ctrl-section h3 { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid var(--border); }
652
1582
 
653
1583
  .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; }
1584
+ .ctrl-label { font-size: 11px; color: var(--text-secondary); margin-bottom: 4px; display: flex; justify-content: space-between; }
1585
+ .ctrl-label .val { color: var(--text-primary); font-weight: 600; }
656
1586
 
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; }
1587
+ input[type="range"] { width: 100%; height: 4px; -webkit-appearance: none; background: var(--bg-elevated); border-radius: 2px; outline: none; }
1588
+ input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: var(--accent); cursor: pointer; }
1589
+ input[type="range"]::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; background: var(--accent); cursor: pointer; border: none; }
660
1590
 
661
- select { width: 100%; background: #111; color: #ccc; border: 1px solid #333; padding: 6px 8px; border-radius: 4px; font-family: inherit; font-size: 12px; }
1591
+ select { width: 100%; background: var(--bg-surface); color: var(--text-primary); border: 1px solid var(--border-subtle); padding: 6px 8px; border-radius: 4px; font-family: inherit; font-size: 12px; }
662
1592
 
663
1593
  .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; }
1594
+ .toggle { position: relative; width: 36px; height: 20px; background: var(--border-subtle); border-radius: 10px; cursor: pointer; transition: background 0.2s; }
1595
+ .toggle.on { background: var(--green); }
666
1596
  .toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; background: #fff; border-radius: 50%; transition: transform 0.2s; }
667
1597
  .toggle.on::after { transform: translateX(16px); }
668
- .toggle-label { font-size: 12px; color: #999; }
1598
+ .toggle-label { font-size: 12px; color: var(--text-secondary); }
669
1599
 
670
1600
  .inject-row { display: flex; gap: 6px; margin-bottom: 6px; }
671
1601
  .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; }
1602
+ .inject-row input { width: 50px; background: var(--bg-surface); color: var(--text-primary); border: 1px solid var(--border-subtle); padding: 4px 6px; border-radius: 4px; font-family: inherit; font-size: 12px; text-align: center; }
673
1603
  .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; }
1604
+ .inject-item { font-size: 11px; color: var(--text-secondary); padding: 3px 6px; background: var(--bg-surface); border-radius: 3px; margin-bottom: 3px; display: flex; justify-content: space-between; }
1605
+ .inject-item .remove { color: var(--red); cursor: pointer; }
676
1606
 
677
1607
  .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; }
1608
+ .btn-run { background: var(--green); color: #0a0a0a; }
679
1609
  .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; }
1610
+ .btn-run:disabled { background: var(--bg-elevated); color: var(--text-faint); cursor: not-allowed; }
1611
+ .btn-add { background: var(--bg-elevated); color: var(--accent); padding: 6px; font-size: 11px; }
1612
+ .btn-add:hover { background: var(--accent-bg); }
683
1613
 
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; }
1614
+ .scenario-btn { display: block; width: 100%; padding: 8px 10px; margin-bottom: 4px; background: var(--bg-surface); border: 1px solid var(--bg-elevated); border-radius: 4px; color: var(--text-primary); font-family: inherit; font-size: 11px; text-align: left; cursor: pointer; }
1615
+ .scenario-btn:hover { border-color: var(--accent); background: var(--accent-bg); }
1616
+ .scenario-btn .stitle { font-weight: 600; color: var(--text-primary); }
1617
+ .scenario-btn .sdesc { color: var(--text-muted); font-size: 10px; margin-top: 2px; }
688
1618
 
689
1619
  /* 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; }
1620
+ .btn-save { background: var(--accent-bg); color: var(--accent); margin-top: 0; }
1621
+ .btn-save:hover { background: var(--accent-bg); filter: brightness(1.2); }
1622
+ .btn-confirm { flex: 1; background: var(--green); color: #0a0a0a; padding: 7px; font-size: 11px; }
1623
+ .btn-cancel { flex: 1; background: var(--bg-elevated); color: var(--text-muted); padding: 7px; font-size: 11px; }
1624
+ .save-input { width: 100%; background: var(--bg-surface); color: var(--text-primary); border: 1px solid var(--border-subtle); padding: 7px 8px; border-radius: 4px; font-family: inherit; font-size: 12px; }
1625
+ .save-input:focus { border-color: var(--accent); outline: none; }
696
1626
 
697
1627
  /* 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; }
1628
+ .variant-card { background: var(--bg-surface); border: 1px solid var(--bg-elevated); border-radius: 4px; padding: 8px 10px; margin-bottom: 4px; cursor: pointer; position: relative; }
1629
+ .variant-card:hover { border-color: var(--green); }
1630
+ .variant-card .vname { font-size: 12px; font-weight: 600; color: var(--text-primary); }
1631
+ .variant-card .vdesc { font-size: 10px; color: var(--text-muted); margin-top: 2px; }
1632
+ .variant-card .vmeta { font-size: 10px; color: var(--text-faint); margin-top: 4px; }
1633
+ .variant-card .vmeta .vresult { color: var(--green); }
1634
+ .variant-card .vdelete { position: absolute; top: 6px; right: 8px; color: var(--red); font-size: 10px; cursor: pointer; opacity: 0; transition: opacity 0.2s; }
705
1635
  .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; }
1636
+ .variant-card .vbase { display: inline-block; font-size: 9px; padding: 1px 5px; background: var(--accent-bg); color: var(--accent); border-radius: 3px; margin-top: 3px; }
707
1637
 
708
1638
  /* RIGHT PANEL — Simulation viewer */
709
1639
  .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; }
1640
+ .viewer-top { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--border); }
1641
+ .viewer-mid { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--border); overflow: hidden; }
1642
+ .viewer-bottom { background: var(--bg-primary); border-top: 1px solid var(--border); padding: 12px 16px; max-height: 180px; overflow-y: auto; }
713
1643
 
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; }
1644
+ .vpanel { background: var(--bg-primary); padding: 14px; overflow-y: auto; }
1645
+ .vpanel h2 { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 10px; }
716
1646
 
717
1647
  /* Metrics */
718
1648
  .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; }
1649
+ .metric-box { background: var(--bg-surface); border: 1px solid var(--bg-elevated); border-radius: 6px; padding: 10px; text-align: center; }
1650
+ .metric-box .value { font-size: 20px; font-weight: 700; color: var(--text-primary); }
1651
+ .metric-box .label { font-size: 10px; color: var(--text-muted); margin-top: 2px; }
1652
+ .metric-box.good .value { color: var(--green); }
1653
+ .metric-box.bad .value { color: var(--red); }
1654
+ .metric-box.warn .value { color: var(--yellow); }
725
1655
 
726
1656
  /* Agent bars */
727
1657
  .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; }
1658
+ .agent-name { width: 130px; color: var(--text-secondary); flex-shrink: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1659
+ .impact-bar-bg { flex: 1; height: 14px; background: var(--bg-surface); border-radius: 3px; position: relative; overflow: hidden; }
730
1660
  .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; }
1661
+ .impact-bar.positive { background: var(--green); right: 50%; }
1662
+ .impact-bar.negative { background: var(--red); left: 50%; }
1663
+ .impact-val { width: 44px; text-align: right; color: var(--text-secondary); font-size: 10px; }
1664
+ .center-line { position: absolute; left: 50%; top: 0; bottom: 0; width: 1px; background: var(--border-subtle); }
735
1665
  .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; }
1666
+ .verdict.ALLOW { background: var(--green-bg); color: var(--green); }
1667
+ .verdict.BLOCK { background: var(--red-bg); color: var(--red); }
1668
+ .verdict.PAUSE { background: var(--yellow-bg); color: var(--yellow); }
739
1669
 
740
1670
  /* Chart */
741
1671
  .chart-container { position: relative; height: 100%; min-height: 150px; }
742
1672
  canvas { width: 100% !important; height: 100% !important; }
743
1673
 
744
1674
  /* 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; }
1675
+ .trace-round { margin-bottom: 10px; border: 1px solid var(--border); border-radius: 4px; overflow: hidden; }
1676
+ .trace-round-header { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--bg-surface); cursor: pointer; user-select: none; }
1677
+ .trace-round-header:hover { background: var(--bg-elevated); }
748
1678
  .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; }
1679
+ .trace-phase.baseline { background: var(--blue-bg); color: var(--blue); }
1680
+ .trace-phase.governed { background: var(--green-bg); color: var(--green); }
1681
+ .trace-round-label { font-size: 11px; color: var(--text-primary); font-weight: 600; }
1682
+ .trace-round-metrics { margin-left: auto; font-size: 10px; color: var(--text-muted); display: flex; gap: 10px; }
753
1683
  .trace-body { padding: 0 10px 8px; }
754
1684
  .trace-body[data-collapsed="true"] { display: none; }
755
1685
  .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; }
1686
+ .trace-section-label { font-size: 9px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 3px; display: flex; align-items: center; gap: 5px; }
757
1687
  .trace-section-label::before { content: ''; display: inline-block; width: 6px; height: 6px; border-radius: 50%; }
758
1688
  .trace-section-label.event::before { background: #f59e0b; }
759
1689
  .trace-section-label.agents::before { background: #3b82f6; }
760
1690
  .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; }
1691
+ .trace-event-item { font-size: 10px; color: var(--text-primary); padding: 3px 0 3px 11px; border-left: 2px solid #f59e0b; margin-left: 2px; }
762
1692
  .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; }
1693
+ .trace-event-severity.major, .trace-event-severity.extreme { background: var(--red-bg); color: var(--red); }
1694
+ .trace-event-severity.moderate { background: var(--yellow-bg); color: var(--yellow); }
1695
+ .trace-event-severity.minor { background: var(--green-bg); color: var(--green); }
766
1696
  .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; }
1697
+ .trace-agent-name { color: var(--blue); font-weight: 500; min-width: 80px; }
1698
+ .trace-agent-action { color: var(--text-secondary); flex: 1; }
769
1699
  .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; }
1700
+ .trace-agent-impact.positive { color: var(--green); }
1701
+ .trace-agent-impact.negative { color: var(--red); }
772
1702
  .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; }
1703
+ .trace-gov-rule { font-size: 9px; color: var(--text-muted); font-family: monospace; }
1704
+ .trace-gov-reason { color: var(--text-secondary); flex: 1; }
1705
+ .trace-dynamics { font-size: 10px; color: var(--purple); padding: 2px 0 2px 11px; border-left: 2px solid #7c3aed; margin-left: 2px; font-style: italic; }
1706
+ .trace-arrow { color: var(--text-faint); font-size: 10px; text-align: center; padding: 2px 0; }
1707
+ .trace-empty { font-size: 10px; color: var(--text-faint); font-style: italic; padding: 4px 0; }
778
1708
 
779
1709
  /* 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; }
1710
+ .world-thesis { font-size: 11px; color: var(--text-secondary); font-style: italic; margin-bottom: 8px; }
1711
+ .rule-card { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; margin-bottom: 8px; transition: border-color 0.2s; }
1712
+ .rule-card:hover { border-color: var(--text-muted); }
1713
+ .rule-card.type-invariant { border-left: 4px solid var(--green); }
1714
+ .rule-card.type-gate { border-left: 4px solid var(--red); }
1715
+ .rule-card.type-warning { border-left: 4px solid var(--yellow); }
1716
+ .rule-card.type-modify { border-left: 4px solid #60a5fa; }
1717
+ .rule-card .rule-header { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
1718
+ .rule-card .rule-icon { font-size: 12px; flex-shrink: 0; }
1719
+ .rule-card .rule-title { font-size: 12px; font-weight: 600; color: var(--text-primary); }
1720
+ .rule-card .rule-desc { font-size: 11px; color: var(--text-secondary); line-height: 1.4; }
1721
+ .rule-card .rule-meta { font-size: 10px; color: var(--text-muted); margin-top: 6px; opacity: 0.8; }
1722
+ .rule-card .rule-why { font-size: 10px; color: var(--text-muted); margin-top: 5px; font-style: italic; padding-top: 5px; border-top: 1px solid var(--border); }
1723
+ .rule-card .rule-impact { display: none; font-size: 10px; margin-top: 6px; padding: 5px 8px; border-radius: 4px; background: var(--bg-surface, var(--bg-secondary)); }
1724
+ .rule-card .rule-impact.visible { display: block; }
1725
+ .rule-card .rule-impact .impact-stat { color: var(--text-primary); font-weight: 600; }
1726
+ .rule-card .rule-impact .impact-label { color: var(--text-muted); }
1727
+ .rule-card.user-rule { border-left-color: #818cf8; background: rgba(129, 140, 248, 0.05); }
1728
+ .rule-source-tag { font-size: 9px; font-weight: 700; color: #818cf8; background: rgba(129, 140, 248, 0.15); padding: 1px 5px; border-radius: 3px; margin-left: auto; letter-spacing: 0.5px; }
782
1729
 
783
1730
  /* Empty state */
784
- .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: #333; }
1731
+ .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: var(--text-faint); }
785
1732
  .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; }
1733
+ .empty-state .msg { font-size: 13px; color: var(--text-muted); }
1734
+ .empty-state .hint { font-size: 11px; color: var(--text-faint); margin-top: 6px; }
788
1735
 
789
1736
  /* 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; }
1737
+ .system-shift { display: none; margin: 12px 16px; border: 1px solid var(--green-bg); border-radius: 8px; background: var(--bg-secondary); overflow: hidden; animation: fadeInUp 0.4s ease; }
791
1738
  .system-shift.visible { display: block; }
792
1739
  @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; }
1740
+ .ss-header { display: flex; align-items: center; gap: 8px; padding: 12px 14px; background: var(--green-bg); border-bottom: 1px solid var(--green-bg); }
1741
+ .ss-icon { width: 8px; height: 8px; border-radius: 50%; background: var(--green); box-shadow: 0 0 6px rgba(74,222,128,0.4); }
1742
+ .ss-title { font-size: 11px; font-weight: 700; color: var(--green); text-transform: uppercase; letter-spacing: 1.5px; }
1743
+ .ss-rule { font-size: 13px; font-weight: 600; color: var(--text-primary); padding: 10px 14px 0; }
797
1744
  .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; }
1745
+ .ss-section { background: var(--bg-surface); border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; }
1746
+ .ss-section-label { font-size: 9px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 6px; }
1747
+ .ss-adapt-rate { font-size: 20px; font-weight: 700; color: var(--green); }
1748
+ .ss-adapt-desc { font-size: 11px; color: var(--text-secondary); margin-top: 2px; }
1749
+ .ss-shift-item { font-size: 11px; color: var(--text-secondary); padding: 2px 0; display: flex; align-items: center; gap: 6px; }
1750
+ .ss-shift-arrow { color: var(--green); font-weight: 600; }
1751
+ .ss-pattern-tag { display: inline-block; font-size: 10px; padding: 2px 8px; background: var(--accent-bg); color: var(--accent); border-radius: 3px; margin-right: 4px; margin-bottom: 4px; }
1752
+ .ss-impact-row { display: flex; justify-content: space-between; align-items: center; font-size: 11px; color: var(--text-secondary); padding: 2px 0; }
1753
+ .ss-impact-delta { color: var(--green); font-weight: 600; }
1754
+ .ss-impact-delta.negative { color: var(--red); }
1755
+ .ss-narrative { font-size: 12px; color: var(--text-primary); line-height: 1.5; font-style: italic; border-left: 2px solid var(--green); padding-left: 10px; }
1756
+ .ss-scale { font-size: 10px; color: var(--text-muted); padding: 0 14px 6px; }
1757
+ .ss-scale strong { color: var(--text-secondary); }
1758
+ .ss-flow { display: flex; align-items: center; gap: 6px; padding: 6px 14px; font-size: 10px; color: var(--text-faint); }
1759
+ .ss-flow-arrow { color: var(--green); }
1760
+ .ss-raw-toggle { display: flex; align-items: center; gap: 6px; padding: 8px 12px; background: var(--bg-secondary); border: none; border-top: 1px solid var(--border); color: var(--text-muted); font-family: inherit; font-size: 10px; cursor: pointer; width: 100%; text-align: left; transition: color 0.2s; }
1761
+ .ss-raw-toggle:hover { color: var(--text-secondary); }
815
1762
  .ss-raw-toggle .arrow { transition: transform 0.2s; }
816
1763
  .ss-raw-toggle.open .arrow { transform: rotate(90deg); }
817
1764
  .ss-raw-detail { max-height: 0; overflow: hidden; transition: max-height 0.3s ease; }
818
1765
  .ss-raw-detail.open { max-height: 200px; overflow-y: auto; }
819
1766
  .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; }
1767
+ .ss-raw-item { font-size: 10px; color: var(--text-muted); padding: 2px 0; display: flex; gap: 6px; }
1768
+ .ss-raw-item .raw-agent { color: var(--text-secondary); min-width: 100px; }
1769
+ .ss-raw-item .raw-action { color: var(--text-secondary); flex: 1; }
823
1770
  .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; }
1771
+ .ss-raw-item .raw-verdict.BLOCK { background: var(--red-bg); color: var(--red); }
1772
+ .ss-raw-item .raw-verdict.MODIFY { background: var(--yellow-bg); color: var(--yellow); }
826
1773
 
827
1774
  /* 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; }
1775
+ .integrate-section { background: var(--accent-bg); border: 1px solid var(--accent-bg); border-radius: 6px; padding: 10px 12px; margin-top: 8px; }
1776
+ .integrate-section h4 { font-size: 10px; color: var(--accent); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
1777
+ .integrate-code { font-size: 10px; color: var(--text-primary); background: var(--bg-surface); border: 1px solid var(--bg-elevated); border-radius: 4px; padding: 8px; overflow-x: auto; white-space: pre; line-height: 1.5; }
1778
+ .integrate-code .kw { color: var(--accent); }
1779
+ .integrate-code .str { color: var(--green); }
1780
+ .integrate-code .comment { color: var(--text-muted); }
1781
+ .integrate-endpoint { font-size: 11px; color: var(--text-secondary); margin-top: 6px; }
1782
+ .integrate-endpoint code { color: var(--green); background: var(--bg-surface); padding: 1px 5px; border-radius: 3px; }
1783
+
1784
+ /* Rule editor */
1785
+ .rule-editor { margin-top: 8px; }
1786
+ .rule-input { width: 100%; min-height: 60px; background: var(--bg-surface); color: var(--text-primary); border: 1px solid var(--border-subtle); padding: 8px; border-radius: 4px; font-family: inherit; font-size: 12px; resize: vertical; line-height: 1.5; }
1787
+ .rule-input:focus { border-color: var(--accent); outline: none; }
1788
+ .rule-input::placeholder { color: var(--text-faint); }
1789
+ .btn-parse { background: var(--accent-bg); color: var(--accent); margin-top: 6px; padding: 8px; font-size: 11px; }
1790
+ .btn-parse:hover { filter: brightness(1.2); }
1791
+ .btn-parse:disabled { opacity: 0.5; cursor: not-allowed; }
1792
+ .parsed-rules { margin-top: 8px; }
1793
+ .parsed-rule { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; margin-bottom: 8px; transition: border-color 0.2s; }
1794
+ .parsed-rule:hover { border-color: var(--text-muted); }
1795
+ .parsed-rule.enforcement-block { border-left: 4px solid var(--red); }
1796
+ .parsed-rule.enforcement-allow { border-left: 4px solid var(--green); }
1797
+ .parsed-rule.enforcement-modify { border-left: 4px solid #60a5fa; }
1798
+ .parsed-rule.enforcement-warn { border-left: 4px solid var(--yellow); }
1799
+ .parsed-rule .pr-header { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
1800
+ .parsed-rule .pr-icon { font-size: 12px; }
1801
+ .parsed-rule .pr-action { font-weight: 700; font-size: 12px; color: var(--text-primary); text-transform: capitalize; }
1802
+ .parsed-rule .pr-desc { color: var(--text-secondary); font-size: 11px; line-height: 1.4; }
1803
+ .parsed-rule .pr-patterns { color: var(--text-muted); font-size: 10px; margin-top: 6px; opacity: 0.8; }
1804
+ .btn-apply-rules { background: var(--green); color: #0a0a0a; margin-top: 6px; padding: 8px; font-size: 11px; }
1805
+ .btn-apply-rules:hover { filter: brightness(0.9); }
1806
+ .rule-status { font-size: 10px; color: var(--text-muted); margin-top: 4px; }
1807
+ .rule-status.success { color: var(--green); }
1808
+ .rule-status.error { color: var(--red); }
1809
+ .rule-examples { font-size: 10px; color: var(--text-faint); margin-top: 6px; line-height: 1.6; }
1810
+ .rule-examples code { background: var(--bg-surface); padding: 1px 4px; border-radius: 2px; color: var(--text-secondary); }
1811
+
1812
+ /* World Action Bar */
1813
+ .world-action-bar { display: flex; gap: 4px; margin-bottom: 16px; flex-wrap: wrap; }
1814
+ .btn-world-action { flex: 1; min-width: 0; padding: 6px 4px; font-size: 10px; background: var(--bg-surface); color: var(--text-secondary); border: 1px solid var(--border); border-radius: 4px; cursor: pointer; font-family: inherit; text-align: center; transition: all 0.2s; white-space: nowrap; }
1815
+ .btn-world-action:hover { border-color: var(--accent); color: var(--accent); }
1816
+ .btn-world-action.btn-export { color: var(--green); border-color: var(--green); opacity: 0.7; }
1817
+ .btn-world-action.btn-export:hover { opacity: 1; }
1818
+
1819
+ /* World Source Tabs */
1820
+ .world-source-tabs { display: flex; gap: 4px; }
1821
+ .ws-tab { flex: 1; display: flex; flex-direction: column; align-items: center; padding: 10px 6px; background: var(--bg-surface); border: 2px solid var(--border); border-radius: 6px; cursor: pointer; transition: all 0.2s; text-align: center; }
1822
+ .ws-tab:hover { border-color: var(--text-muted); }
1823
+ .ws-tab.active { border-color: var(--accent); background: var(--accent-bg); }
1824
+ .ws-tab input[type="radio"] { display: none; }
1825
+ .ws-label { font-size: 11px; font-weight: 700; color: var(--text-primary); }
1826
+ .ws-hint { font-size: 9px; color: var(--text-muted); margin-top: 2px; }
1827
+ .ws-tab.active .ws-label { color: var(--accent); }
1828
+
1829
+ /* World Source Panels */
1830
+ .world-source-panel { animation: fadeIn 0.2s ease; }
1831
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
1832
+
1833
+ /* Custom World Header */
1834
+ .custom-world-header { margin-bottom: 12px; }
1835
+ .world-name-input { width: 100%; background: var(--bg-surface); color: var(--text-primary); border: 1px solid var(--border-subtle); padding: 8px; border-radius: 4px; font-family: inherit; font-size: 13px; font-weight: 600; margin-bottom: 6px; }
1836
+ .world-name-input:focus { border-color: var(--accent); outline: none; }
1837
+ .world-thesis-input { width: 100%; background: var(--bg-surface); color: var(--text-secondary); border: 1px solid var(--border-subtle); padding: 6px 8px; border-radius: 4px; font-family: inherit; font-size: 11px; resize: none; height: 36px; }
1838
+ .world-thesis-input:focus { border-color: var(--accent); outline: none; }
1839
+
1840
+ /* Rule editor enhancements */
1841
+ .rule-editor-label { font-size: 11px; color: var(--text-secondary); margin-bottom: 6px; font-weight: 600; }
1842
+ .rule-input-large { min-height: 120px; }
1843
+ .btn-generate-world { background: var(--accent); color: #fff; margin-top: 8px; padding: 10px; font-size: 12px; font-weight: 700; width: 100%; border: none; border-radius: 4px; cursor: pointer; font-family: inherit; transition: filter 0.2s; }
1844
+ .btn-generate-world:hover { filter: brightness(1.1); }
1845
+
1846
+ /* Upload Zone */
1847
+ .upload-zone { border: 2px dashed var(--border-subtle); border-radius: 8px; padding: 24px; text-align: center; cursor: pointer; transition: all 0.2s; margin-bottom: 12px; }
1848
+ .upload-zone:hover, .upload-zone.dragover { border-color: var(--accent); background: var(--accent-bg); }
1849
+ .upload-icon { font-size: 28px; margin-bottom: 8px; }
1850
+ .upload-label { font-size: 12px; color: var(--text-secondary); }
1851
+ .upload-or { font-size: 10px; color: var(--text-faint); margin: 8px 0; }
1852
+ .btn-upload-browse { background: var(--bg-elevated); color: var(--text-primary); border: 1px solid var(--border); padding: 6px 16px; border-radius: 4px; cursor: pointer; font-family: inherit; font-size: 11px; }
1853
+ .upload-paste-section { margin-bottom: 12px; }
1854
+ .btn-load-world { background: var(--green); color: #0a0a0a; width: 100%; padding: 10px; font-size: 12px; font-weight: 700; border: none; border-radius: 4px; cursor: pointer; font-family: inherit; transition: filter 0.2s; }
1855
+ .btn-load-world:hover { filter: brightness(0.9); }
1856
+
1857
+ /* Loaded World Card */
1858
+ .loaded-world-card { background: var(--green-bg); border: 1px solid var(--green); border-radius: 6px; padding: 12px; margin-top: 10px; }
1859
+ .lw-name { font-size: 13px; font-weight: 700; color: var(--green); }
1860
+ .lw-thesis { font-size: 11px; color: var(--text-secondary); margin-top: 4px; font-style: italic; }
1861
+ .lw-stats { font-size: 10px; color: var(--text-muted); margin-top: 6px; }
1862
+
1863
+ /* Schema Reference */
1864
+ .schema-ref { font-size: 10px; color: var(--text-muted); }
1865
+ .schema-item { padding: 3px 0; border-bottom: 1px solid var(--border); }
1866
+ .schema-item:last-child { border-bottom: none; }
1867
+ .schema-item code { color: var(--accent); background: var(--bg-surface); padding: 1px 4px; border-radius: 2px; }
836
1868
  </style>
837
1869
  </head>
838
1870
  <body>
839
1871
  <div class="header">
840
1872
  <div style="display:flex;align-items:center">
841
1873
  <h1>NV-SIM</h1>
842
- <span class="sub">Scenario Control Platform</span>
1874
+ <span class="sub">Governance Runtime</span>
1875
+ </div>
1876
+ <div class="header-right">
1877
+ <button class="theme-toggle" id="theme-toggle" title="Toggle light/dark mode">Light Mode</button>
1878
+ <span id="status" class="status idle">Ready</span>
843
1879
  </div>
844
- <span id="status" class="status idle">Ready</span>
845
1880
  </div>
846
1881
 
847
1882
  <div class="layout">
848
1883
  <!-- LEFT: CONTROLS -->
849
1884
  <div class="controls" id="controls-panel">
850
- <!-- Simulation Engine selector -->
1885
+ <!-- World Action Bar -->
1886
+ <div class="world-action-bar">
1887
+ <button class="btn btn-world-action" id="new-world-btn" title="Clear everything and start fresh">+ New World</button>
1888
+ <button class="btn btn-world-action" id="load-file-btn" title="Load a .json world file">Load World File</button>
1889
+ <button class="btn btn-world-action" id="clear-rules-btn" title="Clear custom rules only">Clear Rules</button>
1890
+ <button class="btn btn-world-action btn-export" id="export-world-btn" title="Export current world as JSON">Save as World File</button>
1891
+ </div>
1892
+
1893
+ <!-- World Source selector -->
851
1894
  <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>
1895
+ <h3>World Source</h3>
1896
+ <div class="world-source-tabs">
1897
+ <label class="ws-tab active" data-source="preset">
1898
+ <input type="radio" name="world-source" value="preset" checked>
1899
+ <span class="ws-label">Preset</span>
1900
+ <span class="ws-hint">Demo scenarios</span>
1901
+ </label>
1902
+ <label class="ws-tab" data-source="custom">
1903
+ <input type="radio" name="world-source" value="custom">
1904
+ <span class="ws-label">Custom Rules</span>
1905
+ <span class="ws-hint">Define your world</span>
1906
+ </label>
1907
+ <label class="ws-tab" data-source="upload">
1908
+ <input type="radio" name="world-source" value="upload">
1909
+ <span class="ws-label">World File</span>
1910
+ <span class="ws-hint">JSON / .nv-world</span>
1911
+ </label>
857
1912
  </div>
858
- <div id="engine-status" style="font-size:10px;color:#555;margin-top:4px"></div>
859
1913
  </div>
860
1914
 
861
- <!-- World selector -->
862
- <div class="ctrl-section">
863
- <h3>World</h3>
864
- <div class="ctrl-row">
865
- <select id="world-select"></select>
1915
+ <!-- SOURCE: Preset -->
1916
+ <div class="world-source-panel" id="source-preset">
1917
+ <div class="ctrl-section">
1918
+ <h3>World</h3>
1919
+ <div class="ctrl-row">
1920
+ <select id="world-select"></select>
1921
+ </div>
1922
+ <div id="world-thesis" class="world-thesis"></div>
1923
+ </div>
1924
+
1925
+ <!-- State variables (dynamic sliders) -->
1926
+ <div class="ctrl-section" id="state-vars-section" style="display:none">
1927
+ <h3>World Rules</h3>
1928
+ <div id="state-vars"></div>
1929
+ </div>
1930
+
1931
+ <!-- Scenario presets -->
1932
+ <div class="ctrl-section">
1933
+ <h3>Scenarios</h3>
1934
+ <div id="scenario-list"></div>
866
1935
  </div>
867
- <div id="world-thesis" class="world-thesis"></div>
868
1936
  </div>
869
1937
 
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>
1938
+ <!-- SOURCE: Custom Rules (Define Your World) -->
1939
+ <div class="world-source-panel" id="source-custom" style="display:none">
1940
+ <div class="ctrl-section">
1941
+ <h3>Define Your World</h3>
1942
+ <div class="custom-world-header">
1943
+ <input type="text" class="world-name-input" id="custom-world-name" placeholder="World name (e.g. Marketing Governance)">
1944
+ <textarea class="world-thesis-input" id="custom-world-thesis" placeholder="What is this world about? (thesis)"></textarea>
1945
+ </div>
1946
+ <div class="rule-editor">
1947
+ <div class="rule-editor-label">Type your governance rules:</div>
1948
+ <textarea class="rule-input rule-input-large" id="rule-input" placeholder="No agent may spend more than $10k without approval&#10;All outbound emails must be reviewed&#10;Block deletion of production data&#10;Limit API calls to 100 per minute&#10;Require manager approval for refunds over $500"></textarea>
1949
+ <button class="btn btn-generate-world" id="parse-rules-btn">Generate World</button>
1950
+ <div id="parsed-rules" class="parsed-rules"></div>
1951
+ <div id="rule-status" class="rule-status"></div>
1952
+ <div class="rule-examples">
1953
+ Rule patterns:<br>
1954
+ <code>Block [action]</code> — hard suppression<br>
1955
+ <code>Limit [X] to [N]</code> — cap extremes<br>
1956
+ <code>Require [X] for [Y]</code> — structural constraint<br>
1957
+ <code>Pause [X] for review</code> — human-in-the-loop<br>
1958
+ <code>Allow [X]</code> — explicit permission<br>
1959
+ <code>Monitor [X]</code> — circuit breaker gate
1960
+ </div>
1961
+ </div>
1962
+ </div>
1963
+
1964
+ <!-- Base world (optional) -->
1965
+ <div class="ctrl-section">
1966
+ <h3>Base World (Optional)</h3>
1967
+ <div class="ctrl-row">
1968
+ <select id="custom-base-world">
1969
+ <option value="">None — start from scratch</option>
1970
+ </select>
1971
+ <div style="font-size:10px;color:var(--text-faint);margin-top:4px">Layer your rules on top of a preset world</div>
1972
+ </div>
1973
+ </div>
874
1974
  </div>
875
1975
 
876
- <!-- Scenario presets -->
877
- <div class="ctrl-section">
878
- <h3>Scenario Presets</h3>
879
- <div id="scenario-list"></div>
1976
+ <!-- SOURCE: Upload World File -->
1977
+ <div class="world-source-panel" id="source-upload" style="display:none">
1978
+ <div class="ctrl-section">
1979
+ <h3>Load World File</h3>
1980
+ <div class="upload-zone" id="upload-zone">
1981
+ <div class="upload-icon">&#x1F4C4;</div>
1982
+ <div class="upload-label">Drop a .json or .nv-world file here</div>
1983
+ <div class="upload-or">or</div>
1984
+ <button class="btn btn-upload-browse" id="upload-browse-btn">Browse Files</button>
1985
+ <input type="file" id="upload-file-input" accept=".json,.nv-world" style="display:none">
1986
+ </div>
1987
+ <div class="upload-paste-section">
1988
+ <div class="rule-editor-label">Or paste world JSON:</div>
1989
+ <textarea class="rule-input rule-input-large" id="world-json-input" placeholder='{&#10; "name": "Marketing Governance",&#10; "thesis": "All marketing actions are governed",&#10; "invariants": [...],&#10; "rules": [...],&#10; "gates": [...]&#10;}'></textarea>
1990
+ </div>
1991
+ <button class="btn btn-load-world" id="load-world-btn">Load into Runtime</button>
1992
+ <div id="upload-status" class="rule-status"></div>
1993
+
1994
+ <!-- Loaded world info -->
1995
+ <div id="loaded-world-info" style="display:none">
1996
+ <div class="loaded-world-card">
1997
+ <div class="lw-name" id="lw-name"></div>
1998
+ <div class="lw-thesis" id="lw-thesis"></div>
1999
+ <div class="lw-stats" id="lw-stats"></div>
2000
+ </div>
2001
+ </div>
2002
+ </div>
2003
+
2004
+ <!-- World file schema reference -->
2005
+ <div class="ctrl-section">
2006
+ <h3>World File Schema</h3>
2007
+ <div class="schema-ref">
2008
+ <div class="schema-item"><code>name</code> — world name</div>
2009
+ <div class="schema-item"><code>thesis</code> — what this world is about</div>
2010
+ <div class="schema-item"><code>rules[]</code> — governance rules (plain English or structured)</div>
2011
+ <div class="schema-item"><code>invariants[]</code> — rules that always hold <code>{id, description}</code></div>
2012
+ <div class="schema-item"><code>gates[]</code> — viability thresholds <code>{id, label, condition, severity}</code></div>
2013
+ <div class="schema-item"><code>state_variables[]</code> — sliders <code>{id, label, type, range, default_value}</code></div>
2014
+ </div>
2015
+ </div>
2016
+ </div>
2017
+
2018
+ <!-- Simulation Engine (demoted, below world source) -->
2019
+ <div class="ctrl-section" style="margin-top:8px">
2020
+ <h3>Engine</h3>
2021
+ <div class="ctrl-row">
2022
+ <select id="engine-select">
2023
+ <option value="nv-sim" selected>NV-SIM (Built-in)</option>
2024
+ </select>
2025
+ </div>
2026
+ <div id="engine-status" style="font-size:10px;color:var(--text-faint);margin-top:4px"></div>
880
2027
  </div>
881
2028
 
882
2029
  <!-- Narrative injection -->
@@ -951,6 +2098,24 @@ verdict = evaluate(
951
2098
  <span style="display:inline-block;padding:2px 6px;background:#052e16;color:#4ade80;border-radius:3px;margin-left:4px">ALLOW</span> proceeds
952
2099
  </div>
953
2100
  </div>
2101
+
2102
+ <!-- Session Report Panel -->
2103
+ <div class="ctrl-section" id="session-panel">
2104
+ <h3 class="ctrl-label">SESSION</h3>
2105
+ <div class="metric-grid" style="grid-template-columns:1fr 1fr;gap:6px;margin-bottom:8px">
2106
+ <div class="metric-box"><div class="value" id="s-total">0</div><div class="label">Evaluations</div></div>
2107
+ <div class="metric-box"><div class="value" id="s-blocked" style="color:#f87171">0</div><div class="label">Blocked</div></div>
2108
+ <div class="metric-box"><div class="value" id="s-modified" style="color:#fbbf24">0</div><div class="label">Modified</div></div>
2109
+ <div class="metric-box"><div class="value" id="s-allowed" style="color:#4ade80">0</div><div class="label">Allowed</div></div>
2110
+ </div>
2111
+ <div id="s-agents" style="font-size:10px;color:#888;margin-bottom:6px"></div>
2112
+ <div style="display:flex;gap:6px;flex-wrap:wrap">
2113
+ <button onclick="viewSessionReport()" style="flex:1;padding:6px 8px;background:#1e1b4b;color:#818cf8;border:1px solid #312e81;border-radius:4px;cursor:pointer;font-size:11px">View Report</button>
2114
+ <button onclick="resetSession()" style="flex:1;padding:6px 8px;background:#1c1917;color:#a8a29e;border:1px solid #292524;border-radius:4px;cursor:pointer;font-size:11px">Reset &amp; Compare</button>
2115
+ <button onclick="saveExperiment()" style="flex:1;padding:6px 8px;background:#052e16;color:#4ade80;border:1px solid #14532d;border-radius:4px;cursor:pointer;font-size:11px">Save</button>
2116
+ </div>
2117
+ <div id="session-history" style="margin-top:6px;font-size:10px;color:#666"></div>
2118
+ </div>
954
2119
  </div>
955
2120
  </div>
956
2121
 
@@ -1050,6 +2215,7 @@ let governedImpacts = [];
1050
2215
  let chartLabels = [];
1051
2216
  let chart = null;
1052
2217
  let narrativeEventsByRound = {}; // { round: [{ id, headline, severity }] }
2218
+ let ruleImpactTracker = {}; // { ruleId: { blocks: N, label: string } }
1053
2219
 
1054
2220
  const statusEl = document.getElementById('status');
1055
2221
  const worldSelect = document.getElementById('world-select');
@@ -1123,6 +2289,9 @@ async function init() {
1123
2289
  // Load saved variants
1124
2290
  await loadVariants();
1125
2291
 
2292
+ // Populate base world selector for custom rules mode
2293
+ populateBaseWorldSelect();
2294
+
1126
2295
  // Connect SSE
1127
2296
  connectSSE();
1128
2297
  }
@@ -1321,10 +2490,10 @@ function initChart() {
1321
2490
  animation: { duration: 400 },
1322
2491
  responsive: true,
1323
2492
  maintainAspectRatio: false,
1324
- plugins: { legend: { labels: { color: '#888', font: { family: 'monospace', size: 10 } } } },
2493
+ plugins: { legend: { labels: { color: getComputedStyle(document.body).getPropertyValue('--text-muted').trim() || '#888', font: { family: 'monospace', size: 10 } } } },
1325
2494
  scales: {
1326
- x: { ticks: { color: '#555' }, grid: { color: '#1a1a1a' } },
1327
- y: { ticks: { color: '#555' }, grid: { color: '#1a1a1a' }, min: -1, max: 1 }
2495
+ x: { ticks: { color: getComputedStyle(document.body).getPropertyValue('--text-muted').trim() || '#888' }, grid: { color: getComputedStyle(document.body).getPropertyValue('--border').trim() || '#2a2a2a' } },
2496
+ y: { ticks: { color: getComputedStyle(document.body).getPropertyValue('--text-muted').trim() || '#888' }, grid: { color: getComputedStyle(document.body).getPropertyValue('--border').trim() || '#2a2a2a' }, min: -1, max: 1 }
1328
2497
  }
1329
2498
  }
1330
2499
  });
@@ -1461,11 +2630,65 @@ function handleEvent(event) {
1461
2630
  if (!narrativeEventsByRound[ev.round]) narrativeEventsByRound[ev.round] = [];
1462
2631
  narrativeEventsByRound[ev.round].push(ev);
1463
2632
  });
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('');
2633
+ function parseRuleTitle(id, text) {
2634
+ var parts = text.split(/\s*[—–-]{1,}\s*/);
2635
+ var title = parts.length > 1 ? parts[0].trim() : id.replace(/[-_]/g, ' ');
2636
+ var desc = parts.length > 1 ? parts.slice(1).join(' ').trim() : text;
2637
+ title = title.replace(/\b\w/g, function(c) { return c.toUpperCase(); });
2638
+ return { title: title, desc: desc };
2639
+ }
2640
+ function generateWhy(text, type) {
2641
+ var t = text.toLowerCase();
2642
+ if (t.includes('liquidity') || t.includes('drain')) return 'Prevents system collapse from liquidity crises';
2643
+ if (t.includes('panic') || t.includes('cascade')) return 'Stops runaway feedback loops during market shocks';
2644
+ if (t.includes('leverage')) return 'Limits systemic risk from over-leveraged positions';
2645
+ if (t.includes('price') || t.includes('pricing')) return 'Stabilizes pricing mechanisms during volatility';
2646
+ if (t.includes('sentiment') || t.includes('consumer')) return 'Tracks behavioral feedback between price and confidence';
2647
+ if (t.includes('military') || t.includes('escalat')) return 'Models how escalation constrains available options';
2648
+ if (t.includes('diplomatic') || t.includes('window')) return 'Preserves negotiation pathways before they close';
2649
+ if (t.includes('grid') || t.includes('capacity')) return 'Prevents infrastructure overload from demand surges';
2650
+ if (t.includes('election') || t.includes('political')) return 'Captures how political pressure amplifies responses';
2651
+ if (t.includes('supply') || t.includes('energy') || t.includes('oil')) return 'Guards against cascading supply chain disruptions';
2652
+ if (t.includes('fraud') || t.includes('suspicious')) return 'Detects and contains anomalous behavior patterns';
2653
+ if (t.includes('withdraw') || t.includes('limit')) return 'Constrains individual actions to protect system stability';
2654
+ if (type === 'gate') return 'Blocks dangerous actions before they propagate';
2655
+ if (type === 'warning') return 'Provides early warning before thresholds are breached';
2656
+ return 'Maintains system integrity under stress conditions';
2657
+ }
2658
+ // Reset per-rule impact counters
2659
+ ruleImpactTracker = {};
2660
+ activeInvEl.innerHTML = event.invariants.map(function(inv) {
2661
+ var parsed = parseRuleTitle(inv.id, inv.description);
2662
+ var why = generateWhy(inv.description, 'invariant');
2663
+ var isUser = inv.source === 'user';
2664
+ var isFull = inv.enforcement === 'full';
2665
+ var enfLabel = isFull ? 'Fully enforced across system' : 'Advisory only';
2666
+ var enfIcon = isFull ? '&#x2713;' : '&#x26A0;';
2667
+ var sourceTag = isUser ? '<span class="rule-source-tag">USER RULE</span>' : '';
2668
+ ruleImpactTracker[inv.id] = { blocks: 0, label: parsed.title };
2669
+ return '<div class="rule-card type-invariant' + (isUser ? ' user-rule' : '') + '" data-rule-id="' + inv.id + '">' +
2670
+ '<div class="rule-header"><span class="rule-icon">&#x1F7E2;</span><span class="rule-title">' + parsed.title + '</span>' + sourceTag + '</div>' +
2671
+ '<div class="rule-desc">' + parsed.desc + '</div>' +
2672
+ '<div class="rule-meta">Invariant &bull; ' + enfIcon + ' ' + enfLabel + '</div>' +
2673
+ '<div class="rule-why">' + why + '</div>' +
2674
+ '<div class="rule-impact" data-impact-id="' + inv.id + '"></div>' +
2675
+ '</div>';
2676
+ }).join('') + event.gates.map(function(g) {
2677
+ var isCritical = g.severity === 'critical';
2678
+ var typeClass = isCritical ? 'type-gate' : 'type-warning';
2679
+ var icon = isCritical ? '&#x1F534;' : '&#x1F7E1;';
2680
+ var typeLabel = isCritical ? 'Gate' : 'Warning';
2681
+ var effect = isCritical ? 'Blocks actions' : 'Signals risk';
2682
+ var why = generateWhy(g.label + ' ' + (g.condition || ''), isCritical ? 'gate' : 'warning');
2683
+ ruleImpactTracker[g.id] = { blocks: 0, label: g.label };
2684
+ return '<div class="rule-card ' + typeClass + '" data-rule-id="' + g.id + '">' +
2685
+ '<div class="rule-header"><span class="rule-icon">' + icon + '</span><span class="rule-title">' + g.label + '</span></div>' +
2686
+ '<div class="rule-desc">' + (g.condition || g.label) + '</div>' +
2687
+ '<div class="rule-meta">' + typeLabel + ' &bull; ' + effect + '</div>' +
2688
+ '<div class="rule-why">' + why + '</div>' +
2689
+ '<div class="rule-impact" data-impact-id="' + g.id + '"></div>' +
2690
+ '</div>';
2691
+ }).join('');
1469
2692
  initChart();
1470
2693
  }
1471
2694
 
@@ -1502,6 +2725,8 @@ function handleEvent(event) {
1502
2725
  document.getElementById('m-stability').parentElement.className = 'metric-box ' + (r.governed.metrics.stabilityScore > 0.7 ? 'good' : 'warn');
1503
2726
  addLog('Complete. Governance effectiveness: ' + (r.comparison.governanceEffectiveness * 100).toFixed(0) + '%');
1504
2727
  renderSystemShift(r);
2728
+ renderRuleImpacts(r);
2729
+ renderEnforcementClassification(r.enforcementClassification || []);
1505
2730
  lastSimResult = {
1506
2731
  stability: r.governed.metrics.stabilityScore,
1507
2732
  volatility: r.governed.metrics.maxVolatility,
@@ -1514,6 +2739,59 @@ function handleEvent(event) {
1514
2739
  }
1515
2740
  }
1516
2741
 
2742
+ // ============================================
2743
+ // PER-RULE IMPACT RENDERING
2744
+ // ============================================
2745
+ function renderRuleImpacts(result) {
2746
+ var totalBlocks = shiftTracker.blocks;
2747
+ var cascadeAvoided = result && result.governed && result.baseline &&
2748
+ result.governed.metrics.collapseProbability < result.baseline.metrics.collapseProbability;
2749
+
2750
+ Object.keys(ruleImpactTracker).forEach(function(ruleId) {
2751
+ var tracker = ruleImpactTracker[ruleId];
2752
+ var el = document.querySelector('[data-impact-id="' + ruleId + '"]');
2753
+ if (!el) return;
2754
+
2755
+ var html = '';
2756
+ if (tracker.blocks > 0) {
2757
+ html += '<span class="impact-stat">' + tracker.blocks + ' action' + (tracker.blocks > 1 ? 's' : '') + ' blocked</span>';
2758
+ if (cascadeAvoided) {
2759
+ html += ' <span class="impact-label">&rarr; cascade avoided</span>';
2760
+ } else {
2761
+ html += ' <span class="impact-label">&rarr; behavior modified</span>';
2762
+ }
2763
+ } else {
2764
+ // Rule was present but didn't fire — still useful info
2765
+ html += '<span class="impact-label">No violations detected &mdash; agents complied</span>';
2766
+ }
2767
+ el.innerHTML = html;
2768
+ el.classList.add('visible');
2769
+ });
2770
+ }
2771
+
2772
+ // ============================================
2773
+ // ENFORCEMENT CLASSIFICATION — Full vs Advisory
2774
+ // ============================================
2775
+ function renderEnforcementClassification(entries) {
2776
+ if (!entries || entries.length === 0) return;
2777
+ entries.forEach(function(entry) {
2778
+ var card = document.querySelector('[data-rule-id="' + entry.id + '"]');
2779
+ if (!card) return;
2780
+ var metaEl = card.querySelector('.rule-meta');
2781
+ if (!metaEl) return;
2782
+ if (entry.level === 'full' && entry.fired) {
2783
+ metaEl.innerHTML = 'Invariant &bull; &#x2713; Fully enforced &bull; <span style="color:#22c55e;font-weight:600">FIRED</span>';
2784
+ card.style.borderLeftColor = '#22c55e';
2785
+ } else if (entry.level === 'full' && !entry.fired) {
2786
+ metaEl.innerHTML = 'Invariant &bull; &#x2713; Fully enforced &bull; <span style="color:var(--text-muted)">standby</span>';
2787
+ } else if (entry.level === 'advisory') {
2788
+ metaEl.innerHTML = 'Invariant &bull; &#x26A0; Advisory only &bull; <span style="color:var(--text-muted)">monitored</span>';
2789
+ card.style.borderLeftColor = '#6b7280';
2790
+ card.style.opacity = '0.75';
2791
+ }
2792
+ });
2793
+ }
2794
+
1517
2795
  // ============================================
1518
2796
  // SYSTEM SHIFT CARD — The Demo Moment
1519
2797
  // ============================================
@@ -1551,6 +2829,18 @@ function trackShift(event) {
1551
2829
  governed.forEach(function(r) {
1552
2830
  const key = r.verdict.status + ': ' + (r.reaction || 'adapted');
1553
2831
  shiftTracker.shifts[key] = (shiftTracker.shifts[key] || 0) + 1;
2832
+ // Track per-rule impacts for card display
2833
+ var ruleId = r.verdict.ruleId || '';
2834
+ if (ruleId && ruleImpactTracker[ruleId]) {
2835
+ ruleImpactTracker[ruleId].blocks++;
2836
+ } else {
2837
+ // Try to match by pattern — governance may report with NV- prefix
2838
+ Object.keys(ruleImpactTracker).forEach(function(k) {
2839
+ if (ruleId.includes(k) || (r.verdict.reason && r.verdict.reason.toLowerCase().includes(ruleImpactTracker[k].label.toLowerCase()))) {
2840
+ ruleImpactTracker[k].blocks++;
2841
+ }
2842
+ });
2843
+ }
1554
2844
  // Store raw governed reactions for detail view
1555
2845
  shiftTracker.rawGoverned.push({
1556
2846
  agent: r.stakeholder_id,
@@ -1848,6 +3138,480 @@ handleEvent = function(event) {
1848
3138
  }
1849
3139
  };
1850
3140
 
3141
+ // ============================================
3142
+ // THEME TOGGLE — Light / Dark mode
3143
+ // ============================================
3144
+ const themeToggleBtn = document.getElementById('theme-toggle');
3145
+ function applyTheme(theme) {
3146
+ if (theme === 'light') {
3147
+ document.body.classList.add('light');
3148
+ themeToggleBtn.textContent = 'Dark Mode';
3149
+ } else {
3150
+ document.body.classList.remove('light');
3151
+ themeToggleBtn.textContent = 'Light Mode';
3152
+ }
3153
+ localStorage.setItem('nv-theme', theme);
3154
+ // Update chart colors if chart exists
3155
+ if (chart && chart.options) {
3156
+ const gridColor = theme === 'light' ? '#d4d4d4' : '#2a2a2a';
3157
+ const tickColor = theme === 'light' ? '#666' : '#888';
3158
+ const legendColor = theme === 'light' ? '#444' : '#888';
3159
+ chart.options.scales.x.ticks.color = tickColor;
3160
+ chart.options.scales.x.grid.color = gridColor;
3161
+ chart.options.scales.y.ticks.color = tickColor;
3162
+ chart.options.scales.y.grid.color = gridColor;
3163
+ chart.options.plugins.legend.labels.color = legendColor;
3164
+ chart.update();
3165
+ }
3166
+ }
3167
+ themeToggleBtn.addEventListener('click', () => {
3168
+ const current = document.body.classList.contains('light') ? 'light' : 'dark';
3169
+ applyTheme(current === 'light' ? 'dark' : 'light');
3170
+ });
3171
+ // Restore saved theme
3172
+ const savedTheme = localStorage.getItem('nv-theme');
3173
+ if (savedTheme) applyTheme(savedTheme);
3174
+
3175
+ // ============================================
3176
+ // WORLD SOURCE SWITCHING
3177
+ // ============================================
3178
+ let currentWorldSource = 'preset';
3179
+ const worldSourceTabs = document.querySelectorAll('.ws-tab');
3180
+ const sourcePresetPanel = document.getElementById('source-preset');
3181
+ const sourceCustomPanel = document.getElementById('source-custom');
3182
+ const sourceUploadPanel = document.getElementById('source-upload');
3183
+
3184
+ worldSourceTabs.forEach(tab => {
3185
+ tab.addEventListener('click', () => {
3186
+ const source = tab.dataset.source;
3187
+ if (source === currentWorldSource) return;
3188
+
3189
+ currentWorldSource = source;
3190
+
3191
+ // Update tab visuals
3192
+ worldSourceTabs.forEach(t => t.classList.remove('active'));
3193
+ tab.classList.add('active');
3194
+ tab.querySelector('input').checked = true;
3195
+
3196
+ // Show/hide panels
3197
+ sourcePresetPanel.style.display = source === 'preset' ? '' : 'none';
3198
+ sourceCustomPanel.style.display = source === 'custom' ? '' : 'none';
3199
+ sourceUploadPanel.style.display = source === 'upload' ? '' : 'none';
3200
+ });
3201
+ });
3202
+
3203
+ // Populate base world selector in custom rules panel
3204
+ function populateBaseWorldSelect() {
3205
+ const select = document.getElementById('custom-base-world');
3206
+ if (!select) return;
3207
+ worlds.forEach(w => {
3208
+ const opt = document.createElement('option');
3209
+ opt.value = w.id;
3210
+ opt.textContent = w.title;
3211
+ select.appendChild(opt);
3212
+ });
3213
+ }
3214
+
3215
+ // ============================================
3216
+ // WORLD ACTION BAR
3217
+ // ============================================
3218
+
3219
+ // + New World
3220
+ document.getElementById('new-world-btn').addEventListener('click', () => {
3221
+ // Switch to custom rules mode
3222
+ currentWorldSource = 'custom';
3223
+ worldSourceTabs.forEach(t => {
3224
+ t.classList.toggle('active', t.dataset.source === 'custom');
3225
+ t.querySelector('input').checked = t.dataset.source === 'custom';
3226
+ });
3227
+ sourcePresetPanel.style.display = 'none';
3228
+ sourceCustomPanel.style.display = '';
3229
+ sourceUploadPanel.style.display = 'none';
3230
+
3231
+ // Clear everything
3232
+ document.getElementById('custom-world-name').value = '';
3233
+ document.getElementById('custom-world-thesis').value = '';
3234
+ document.getElementById('rule-input').value = '';
3235
+ document.getElementById('parsed-rules').innerHTML = '';
3236
+ document.getElementById('rule-status').textContent = '';
3237
+ document.getElementById('rule-status').className = 'rule-status';
3238
+ document.getElementById('custom-base-world').value = '';
3239
+
3240
+ // Clear active rules server-side
3241
+ fetch('/api/clear-rules', { method: 'POST' });
3242
+
3243
+ // Reset right panel
3244
+ document.getElementById('active-invariants').innerHTML = '<div style="font-size:11px;color:var(--text-muted)">No rules loaded. Define your world.</div>';
3245
+ });
3246
+
3247
+ // Clear Rules
3248
+ document.getElementById('clear-rules-btn').addEventListener('click', async () => {
3249
+ await fetch('/api/clear-rules', { method: 'POST' });
3250
+
3251
+ // Clear rule editor UI
3252
+ const ruleInput = document.getElementById('rule-input');
3253
+ if (ruleInput) ruleInput.value = '';
3254
+ const parsed = document.getElementById('parsed-rules');
3255
+ if (parsed) parsed.innerHTML = '';
3256
+ const status = document.getElementById('rule-status');
3257
+ if (status) { status.textContent = 'Rules cleared.'; status.className = 'rule-status success'; }
3258
+
3259
+ // Clear upload state
3260
+ const uploadStatus = document.getElementById('upload-status');
3261
+ if (uploadStatus) { uploadStatus.textContent = 'Rules cleared.'; uploadStatus.className = 'rule-status success'; }
3262
+ const loadedInfo = document.getElementById('loaded-world-info');
3263
+ if (loadedInfo) loadedInfo.style.display = 'none';
3264
+ });
3265
+
3266
+ // Load World File (switch to upload tab)
3267
+ document.getElementById('load-file-btn').addEventListener('click', () => {
3268
+ currentWorldSource = 'upload';
3269
+ worldSourceTabs.forEach(t => {
3270
+ t.classList.toggle('active', t.dataset.source === 'upload');
3271
+ t.querySelector('input').checked = t.dataset.source === 'upload';
3272
+ });
3273
+ sourcePresetPanel.style.display = 'none';
3274
+ sourceCustomPanel.style.display = 'none';
3275
+ sourceUploadPanel.style.display = '';
3276
+ });
3277
+
3278
+ // Save as World File (export)
3279
+ document.getElementById('export-world-btn').addEventListener('click', async () => {
3280
+ try {
3281
+ const resp = await fetch('/api/export-world');
3282
+ const data = await resp.json();
3283
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
3284
+ const url = URL.createObjectURL(blob);
3285
+ const a = document.createElement('a');
3286
+ a.href = url;
3287
+ a.download = (currentWorld ? currentWorld.id : 'custom') + '-world.json';
3288
+ document.body.appendChild(a);
3289
+ a.click();
3290
+ document.body.removeChild(a);
3291
+ URL.revokeObjectURL(url);
3292
+ } catch (err) {
3293
+ alert('Export failed: ' + err.message);
3294
+ }
3295
+ });
3296
+
3297
+ // ============================================
3298
+ // WORLD FILE UPLOAD / PASTE
3299
+ // ============================================
3300
+ const uploadZone = document.getElementById('upload-zone');
3301
+ const uploadFileInput = document.getElementById('upload-file-input');
3302
+ const uploadBrowseBtn = document.getElementById('upload-browse-btn');
3303
+ const worldJsonInput = document.getElementById('world-json-input');
3304
+ const loadWorldBtn = document.getElementById('load-world-btn');
3305
+ const uploadStatusEl = document.getElementById('upload-status');
3306
+
3307
+ // Drag and drop
3308
+ uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.classList.add('dragover'); });
3309
+ uploadZone.addEventListener('dragleave', () => { uploadZone.classList.remove('dragover'); });
3310
+ uploadZone.addEventListener('drop', (e) => {
3311
+ e.preventDefault();
3312
+ uploadZone.classList.remove('dragover');
3313
+ const file = e.dataTransfer.files[0];
3314
+ if (file) readWorldFile(file);
3315
+ });
3316
+
3317
+ // Browse button
3318
+ uploadBrowseBtn.addEventListener('click', (e) => { e.stopPropagation(); uploadFileInput.click(); });
3319
+ uploadFileInput.addEventListener('change', () => {
3320
+ if (uploadFileInput.files[0]) readWorldFile(uploadFileInput.files[0]);
3321
+ });
3322
+
3323
+ // Click zone to browse
3324
+ uploadZone.addEventListener('click', () => { uploadFileInput.click(); });
3325
+
3326
+ function readWorldFile(file) {
3327
+ const reader = new FileReader();
3328
+ reader.onload = (e) => {
3329
+ worldJsonInput.value = e.target.result;
3330
+ uploadStatusEl.textContent = 'File loaded: ' + file.name + '. Click "Load into Runtime".';
3331
+ uploadStatusEl.className = 'rule-status success';
3332
+ };
3333
+ reader.readAsText(file);
3334
+ }
3335
+
3336
+ // Load into Runtime
3337
+ loadWorldBtn.addEventListener('click', async () => {
3338
+ const jsonText = worldJsonInput.value.trim();
3339
+ if (!jsonText) {
3340
+ uploadStatusEl.textContent = 'Paste or upload a world file first.';
3341
+ uploadStatusEl.className = 'rule-status error';
3342
+ return;
3343
+ }
3344
+
3345
+ let worldData;
3346
+ try {
3347
+ worldData = JSON.parse(jsonText);
3348
+ } catch (err) {
3349
+ uploadStatusEl.textContent = 'Invalid JSON: ' + err.message;
3350
+ uploadStatusEl.className = 'rule-status error';
3351
+ return;
3352
+ }
3353
+
3354
+ // Normalize: if the JSON is { world: {...} } or just {...}
3355
+ const worldPayload = worldData.world || worldData;
3356
+
3357
+ loadWorldBtn.textContent = 'Loading...';
3358
+ loadWorldBtn.disabled = true;
3359
+
3360
+ try {
3361
+ const resp = await fetch('/api/load-world-file', {
3362
+ method: 'POST',
3363
+ headers: { 'Content-Type': 'application/json' },
3364
+ body: JSON.stringify({ world: worldPayload }),
3365
+ });
3366
+ const result = await resp.json();
3367
+
3368
+ if (result.error) {
3369
+ uploadStatusEl.textContent = result.error;
3370
+ uploadStatusEl.className = 'rule-status error';
3371
+ } else {
3372
+ uploadStatusEl.textContent = result.message;
3373
+ uploadStatusEl.className = 'rule-status success';
3374
+
3375
+ // Show loaded world info
3376
+ const infoEl = document.getElementById('loaded-world-info');
3377
+ infoEl.style.display = '';
3378
+ document.getElementById('lw-name').textContent = result.world.title;
3379
+ document.getElementById('lw-thesis').textContent = '"' + result.world.thesis + '"';
3380
+ document.getElementById('lw-stats').textContent =
3381
+ result.world.invariants.length + ' invariants, ' +
3382
+ result.world.gates.length + ' gates, ' +
3383
+ result.rulesApplied + ' rules';
3384
+
3385
+ // Update active invariants in right panel
3386
+ const invHtml = result.world.invariants.map(inv =>
3387
+ '<div class="inv-item">[' + inv.id + '] ' + inv.description + '</div>'
3388
+ ).join('') + result.world.gates.map(g =>
3389
+ '<div class="inv-item" style="color:' + (g.severity === 'critical' ? 'var(--red)' : 'var(--yellow)') + '">[' + g.id + '] ' + g.label + '</div>'
3390
+ ).join('');
3391
+ activeInvEl.innerHTML = invHtml || '<div style="font-size:11px;color:var(--text-muted)">No invariants defined</div>';
3392
+
3393
+ // Update state variables if present
3394
+ if (result.world.stateVariables && result.world.stateVariables.length > 0) {
3395
+ // Store as a pseudo-world so sliders render
3396
+ currentWorld = {
3397
+ id: 'custom-world',
3398
+ title: result.world.title,
3399
+ thesis: result.world.thesis,
3400
+ stateVariables: result.world.stateVariables,
3401
+ invariants: result.world.invariants,
3402
+ gates: result.world.gates,
3403
+ };
3404
+ selectWorld('custom-world');
3405
+ } else {
3406
+ // Just set current world reference
3407
+ currentWorld = {
3408
+ id: 'custom-world',
3409
+ title: result.world.title,
3410
+ thesis: result.world.thesis,
3411
+ stateVariables: [],
3412
+ invariants: result.world.invariants,
3413
+ gates: result.world.gates,
3414
+ };
3415
+ }
3416
+ }
3417
+ } catch (err) {
3418
+ uploadStatusEl.textContent = 'Error: ' + err.message;
3419
+ uploadStatusEl.className = 'rule-status error';
3420
+ }
3421
+
3422
+ loadWorldBtn.textContent = 'Load into Runtime';
3423
+ loadWorldBtn.disabled = false;
3424
+ });
3425
+
3426
+ // ============================================
3427
+ // PLAIN-ENGLISH RULE EDITOR
3428
+ // ============================================
3429
+ const ruleInput = document.getElementById('rule-input');
3430
+ const parseRulesBtn = document.getElementById('parse-rules-btn');
3431
+ const parsedRulesEl = document.getElementById('parsed-rules');
3432
+ const ruleStatusEl = document.getElementById('rule-status');
3433
+ let parsedRuleData = [];
3434
+
3435
+ parseRulesBtn.addEventListener('click', async () => {
3436
+ const text = ruleInput.value.trim();
3437
+ if (!text) return;
3438
+
3439
+ parseRulesBtn.disabled = true;
3440
+ parseRulesBtn.textContent = 'Parsing...';
3441
+ ruleStatusEl.textContent = '';
3442
+ ruleStatusEl.className = 'rule-status';
3443
+
3444
+ try {
3445
+ const resp = await fetch('/api/parse-rules', {
3446
+ method: 'POST',
3447
+ headers: { 'Content-Type': 'application/json' },
3448
+ body: JSON.stringify({ text, worldId: currentWorld ? currentWorld.id : 'trading' }),
3449
+ });
3450
+ const data = await resp.json();
3451
+
3452
+ if (data.error) {
3453
+ ruleStatusEl.textContent = data.error;
3454
+ ruleStatusEl.className = 'rule-status error';
3455
+ parsedRulesEl.innerHTML = '';
3456
+ parsedRuleData = [];
3457
+ } else {
3458
+ parsedRuleData = data.rules || [];
3459
+ parsedRulesEl.innerHTML = parsedRuleData.map((r, i) => {
3460
+ const enfType = r.enforcement || 'block';
3461
+ const iconMap = { block: '&#x1F534;', allow: '&#x1F7E2;', modify: '&#x1F535;', warn: '&#x1F7E1;', pause: '&#x1F7E1;' };
3462
+ const labelMap = { block: 'Gate', allow: 'Invariant', modify: 'Modifier', warn: 'Warning', pause: 'Warning' };
3463
+ const effectMap = { block: 'Blocks actions', allow: 'Always enforced', modify: 'Adjusts behavior', warn: 'Signals risk', pause: 'Signals risk' };
3464
+ const icon = iconMap[enfType] || '&#x1F7E2;';
3465
+ const label = labelMap[enfType] || 'Rule';
3466
+ const effect = effectMap[enfType] || 'Active';
3467
+ return '<div class="parsed-rule enforcement-' + enfType + '">' +
3468
+ '<div class="pr-header"><span class="pr-icon">' + icon + '</span><span class="pr-action">' + label + '</span></div>' +
3469
+ '<div class="pr-desc">' + r.description + '</div>' +
3470
+ '<div class="pr-patterns">' + effect + ' &bull; Matches: ' + r.intent_patterns.join(', ') + '</div>' +
3471
+ '</div>';
3472
+ }).join('');
3473
+
3474
+ if (parsedRuleData.length > 0) {
3475
+ const btnLabel = currentWorldSource === 'custom' ? 'Generate World with ' + parsedRuleData.length + ' Rule' + (parsedRuleData.length > 1 ? 's' : '') : 'Apply ' + parsedRuleData.length + ' Rule' + (parsedRuleData.length > 1 ? 's' : '') + ' to Simulation';
3476
+ parsedRulesEl.innerHTML += '<button class="btn btn-apply-rules" id="apply-rules-btn">' + btnLabel + '</button>';
3477
+ document.getElementById('apply-rules-btn').addEventListener('click', async () => {
3478
+ try {
3479
+ // If in custom rules mode, use base world if selected
3480
+ let worldId = currentWorld ? currentWorld.id : 'trading';
3481
+ if (currentWorldSource === 'custom') {
3482
+ const baseWorld = document.getElementById('custom-base-world').value;
3483
+ if (baseWorld) worldId = baseWorld;
3484
+ }
3485
+
3486
+ const applyResp = await fetch('/api/apply-rules', {
3487
+ method: 'POST',
3488
+ headers: { 'Content-Type': 'application/json' },
3489
+ body: JSON.stringify({ rules: parsedRuleData, worldId }),
3490
+ });
3491
+ const applyData = await applyResp.json();
3492
+ if (applyData.status === 'applied') {
3493
+ ruleStatusEl.textContent = applyData.applied + ' rule(s) active. Run a simulation to see the effect.';
3494
+ ruleStatusEl.className = 'rule-status success';
3495
+
3496
+ // Update right panel invariants with custom rules
3497
+ const customName = document.getElementById('custom-world-name');
3498
+ const worldName = (customName && customName.value) ? customName.value : 'Custom World';
3499
+ const customThesis = document.getElementById('custom-world-thesis');
3500
+ const thesis = (customThesis && customThesis.value) ? customThesis.value : 'User-defined governance rules';
3501
+
3502
+ // Show rules in active invariants panel
3503
+ activeInvEl.innerHTML = parsedRuleData.map(r => {
3504
+ const enfType = r.enforcement || 'block';
3505
+ const colorMap = { block: 'var(--red)', allow: 'var(--green)', modify: 'var(--blue)', warn: 'var(--yellow)', pause: 'var(--yellow)' };
3506
+ const color = colorMap[enfType] || 'var(--text-secondary)';
3507
+ return '<div class="inv-item" style="color:' + color + '">[' + r.id + '] ' + r.description + '</div>';
3508
+ }).join('');
3509
+
3510
+ // In custom mode, set a custom world reference
3511
+ if (currentWorldSource === 'custom') {
3512
+ const baseWorld = document.getElementById('custom-base-world').value;
3513
+ if (baseWorld) {
3514
+ selectWorld(baseWorld);
3515
+ } else {
3516
+ currentWorld = { id: 'custom-world', title: worldName, thesis, stateVariables: [], invariants: [], gates: [] };
3517
+ }
3518
+ document.getElementById('world-thesis').textContent = '"' + thesis + '"';
3519
+ }
3520
+ }
3521
+ } catch (err) {
3522
+ ruleStatusEl.textContent = 'Error applying rules: ' + err.message;
3523
+ ruleStatusEl.className = 'rule-status error';
3524
+ }
3525
+ });
3526
+ ruleStatusEl.textContent = 'Parsed ' + parsedRuleData.length + ' rule(s). Review and click ' + (currentWorldSource === 'custom' ? 'Generate.' : 'Apply.');
3527
+ ruleStatusEl.className = 'rule-status success';
3528
+ }
3529
+ }
3530
+ } catch (err) {
3531
+ ruleStatusEl.textContent = 'Error: ' + err.message;
3532
+ ruleStatusEl.className = 'rule-status error';
3533
+ }
3534
+
3535
+ parseRulesBtn.disabled = false;
3536
+ parseRulesBtn.textContent = 'Parse Rules';
3537
+ });
3538
+
3539
+ // ============================================
3540
+ // SESSION TRACKING
3541
+ // ============================================
3542
+
3543
+ let sessionPollInterval = null;
3544
+
3545
+ async function pollSessionStats() {
3546
+ try {
3547
+ const resp = await fetch('/api/session');
3548
+ const data = await resp.json();
3549
+ const el = (id) => document.getElementById(id);
3550
+ if (el('s-total')) el('s-total').textContent = data.evaluations.total;
3551
+ if (el('s-blocked')) el('s-blocked').textContent = data.evaluations.blocked;
3552
+ if (el('s-modified')) el('s-modified').textContent = data.evaluations.modified;
3553
+ if (el('s-allowed')) el('s-allowed').textContent = data.evaluations.allowed;
3554
+ if (el('s-agents')) {
3555
+ el('s-agents').textContent = data.agents.length > 0
3556
+ ? data.agents.length + ' agent(s): ' + data.agents.slice(0, 5).join(', ') + (data.agents.length > 5 ? '...' : '')
3557
+ : 'No agents connected yet';
3558
+ }
3559
+ if (el('session-history') && data.historyCount > 0) {
3560
+ el('session-history').textContent = data.historyCount + ' previous session(s) saved for comparison';
3561
+ }
3562
+ } catch {}
3563
+ }
3564
+
3565
+ async function viewSessionReport() {
3566
+ try {
3567
+ const resp = await fetch('/api/session/report');
3568
+ const text = await resp.text();
3569
+ const log = document.getElementById('sim-log');
3570
+ if (log) {
3571
+ const div = document.createElement('div');
3572
+ div.className = 'log-round';
3573
+ div.innerHTML = '<h4 style="color:#818cf8">Enforcement Report</h4><pre style="white-space:pre-wrap;font-size:11px;color:#d4d4d8">' + text.replace(/</g,'&lt;') + '</pre>';
3574
+ log.prepend(div);
3575
+ log.scrollTop = 0;
3576
+ }
3577
+ } catch (err) { console.error('Failed to load report', err); }
3578
+ }
3579
+
3580
+ async function resetSession() {
3581
+ if (!confirm('Reset session? Current data will be saved for comparison.')) return;
3582
+ try {
3583
+ const resp = await fetch('/api/session/reset', { method: 'POST' });
3584
+ const data = await resp.json();
3585
+ const log = document.getElementById('sim-log');
3586
+ if (log) {
3587
+ const div = document.createElement('div');
3588
+ div.className = 'log-round';
3589
+ div.innerHTML = '<h4 style="color:#fbbf24">Session Reset</h4><div style="font-size:11px;color:#a8a29e">' + data.message + '</div>';
3590
+ log.prepend(div);
3591
+ }
3592
+ pollSessionStats();
3593
+ } catch (err) { console.error('Failed to reset session', err); }
3594
+ }
3595
+
3596
+ async function saveExperiment() {
3597
+ try {
3598
+ const resp = await fetch('/api/session/save', { method: 'POST' });
3599
+ const data = await resp.json();
3600
+ if (data.error) { alert(data.error); return; }
3601
+ const log = document.getElementById('sim-log');
3602
+ if (log) {
3603
+ const div = document.createElement('div');
3604
+ div.className = 'log-round';
3605
+ div.innerHTML = '<h4 style="color:#4ade80">Experiment Saved</h4><div style="font-size:11px;color:#a8a29e">ID: ' + data.experimentId + '<br>File: ' + data.filePath + '<br>Stability: ' + (data.metrics.stability * 100).toFixed(0) + '% | Effectiveness: ' + (data.metrics.effectiveness * 100).toFixed(0) + '%</div>';
3606
+ log.prepend(div);
3607
+ }
3608
+ } catch (err) { console.error('Failed to save experiment', err); }
3609
+ }
3610
+
3611
+ // Poll session stats every 2 seconds
3612
+ sessionPollInterval = setInterval(pollSessionStats, 2000);
3613
+ pollSessionStats();
3614
+
1851
3615
  // ============================================
1852
3616
  // BOOT
1853
3617
  // ============================================