Skip to content

Design — COP UI v2: surface the new featureset (Tier 1)

Pre-code design memo for the COP UI enhancement that follows the @uci-demo/game and @uci-demo/solver workstreams. The COP today shows what we had at Phase 0 day 0; we have shipped substantial machinery (Bayesian identity belief, payoff scoring, 8 doctrinal subroutines, ES-MCCFR kernel, blueprint serialization) that the UI does not surface.

This memo locks the contracts for Tier 1 — the surfaces that must exist before the SBIR-proposal demo can show the interpretability story. Tier 2 (solver-daemon integration) and Tier 3 (researcher UX, what-if simulator, multi-scenario comparison) are out of scope.

The goal: a reviewer running pnpm run up and watching Operation Tripwire can see the Bayesian picture evolve, the doctrinal subroutines firing, the payoff accumulating, and the per-track interpretability chain end-to-end — without opening DevTools.


What's in scope (Tier 1)

  1. BeliefBar — per-track 6-bucket Bayesian posterior stripe
  2. ScoreHud — running PayoffCounters + bluePayoff headline
  3. DoctrineStack — 8-slot subroutine panel with weights + rationales
  4. TrackTimeline — side-drawer per-track event chronology (the end-to-end interpretability artifact)

Plus the copilot-side publisher work that makes the data flow:

  1. Belief-state mirror in the copilot (uses updateIdentityBelief)
  2. Payoff-counter aggregator (subscribes to engagement events, maintains running PayoffCounters)
  3. Strategy-bank cold-start path (instantiate createDefaultBank() against an empty RegretTable so doctrine traces fire even before solver-daemon ships)

MQTT side-channels

Three new channels, all under the existing uci-demo/ namespace (free-form JSON, application-internal, not schema-validated). CLAUDE.md already documents the convention.

uci-demo/copilot/belief/<trackId> (retained)

{
  "schemaV": 1,
  "trackId": "TRACK-001",
  "distribution": {
    "UNKNOWN": 0.18,
    "ASSUMED_FRIEND": 0.04,
    "FRIEND": 0.01,
    "NEUTRAL": 0.03,
    "SUSPECT": 0.28,
    "HOSTILE": 0.46
  },
  "mostLikely": "HOSTILE",
  "maxP": 0.46,
  "ts": 1779313601317,
  "beliefV": 1
}
  • Producer: copilot, on every EntityNotificationMT or EntityMT ingestion. Computed by updateIdentityBelief(prior, obs) from @uci-demo/game/belief.
  • Retention: retained, so late subscribers (the UI on reconnect) see the current belief immediately.
  • Cleanup: on EntityLostMT, publish an empty payload to clear the retained message (same pattern as the existing ROE retain).

uci-demo/copilot/score (retained)

{
  "schemaV": 1,
  "counters": {
    "neutralizedHostiles": 2,
    "fratricideEvents": 0,
    "roeViolations": 1,
    "fuelBurnedTotal": 0.18,
    "failedEffects": 1,
    "commsDegradeSeconds": 23,
    "meanTimeToDecisionMs": 2430
  },
  "blueScore": 1.6826,
  "ts": 1779313601317,
  "payoffV": 1
}
  • Producer: copilot, debounced 1s. Counter updates triggered by:
  • EntityLostMT for HOSTILE/SUSPECT → neutralizedHostiles++
  • EntityLostMT for FRIEND inside any CapabilityCoverageAreaMT polygon → fratricideEvents++ (loud and red in the HUD)
  • EffectPlanCommandMT with kinetic effect under ROE GREEN → roeViolations++
  • EffectStatusMT.state === "FAILED"failedEffects++
  • SubsystemStatusMT.state band drop → estimated fuelBurnedTotal contribution (LOW=0.05, CRITICAL=0.15 per drop)
  • uci-demo/world/degrade window integral → commsDegradeSeconds
  • Copilot's own evaluate() wall-clock → running mean of meanTimeToDecisionMs
  • blueScore = bluePayoff(counters) from @uci-demo/game/payoff.

uci-demo/copilot/doctrine/<planId> (not retained)

{
  "schemaV": 1,
  "planId": "PLAN-002",
  "traces": [
    {
      "id": "IdentityGate",
      "weight": 0.61,
      "topAction": { "kind": "withhold", "trackId": "TRACK-001", "reason": "policy" },
      "rationale": "P(FRIEND)=0.52 on TRACK-001 — gate active, prefer withhold"
    },
    {
      "id": "FratricideAvoidance",
      "weight": 0.28,
      "topAction": { "kind": "withhold", "trackId": "TRACK-001", "reason": "policy" },
      "rationale": "P(FRIEND)=0.52 on TRACK-001 — soft fratricide guard, withhold"
    },
    {
      "id": "RoeEscalation",
      "weight": 0.06,
      "topAction": null,
      "rationale": "ROE=AMBER — no escalation"
    }
  ],
  "ts": 1779313601317
}
  • Producer: copilot at decision time. Calls createDefaultBank().explain(ctx, regretTable) against the current GameState projection. Phase 0 uses an empty regret table; the output is the subroutine prior alone. When solver-daemon ships (weeks 5-7), the same call uses the trained regret table.
  • Trace ordering: by descending weight (post-softmax mass). Only traces with weight > 0.05 are emitted (the bank's default minWeight).

Channels NOT in this memo (deferred)

  • uci-demo/solver/status — Tier 2, lands with solver-daemon
  • uci-demo/copilot/policy/<planId> — Tier 2, action distribution from the solver

Store extensions (zustand lib/store.ts)

// Existing CopState gains:

interface BeliefDistribution {
  readonly UNKNOWN: number;
  readonly ASSUMED_FRIEND: number;
  readonly FRIEND: number;
  readonly NEUTRAL: number;
  readonly SUSPECT: number;
  readonly HOSTILE: number;
}

interface BeliefSnapshot {
  readonly distribution: BeliefDistribution;
  readonly mostLikely: keyof BeliefDistribution;
  readonly maxP: number;
  readonly ts: number;
}

interface SubroutineTraceLite {
  readonly id: string;
  readonly weight: number;
  readonly topAction: { readonly kind: string; readonly trackId?: string } | null;
  readonly rationale: string;
}

interface ScoreSnapshot {
  readonly counters: {
    readonly neutralizedHostiles: number;
    readonly fratricideEvents: number;
    readonly roeViolations: number;
    readonly fuelBurnedTotal: number;
    readonly failedEffects: number;
    readonly commsDegradeSeconds: number;
    readonly meanTimeToDecisionMs: number;
  };
  readonly blueScore: number;
  readonly ts: number;
}

interface CopState {
  // ...existing fields unchanged

  /** Bayesian posterior per trackId, populated from uci-demo/copilot/belief/<trackId>. */
  beliefs: Record<string, BeliefSnapshot>;
  /** Belief history per trackId — last N snapshots, newest last. For the TrackTimeline. */
  beliefHistory: Record<string, BeliefSnapshot[]>;
  /** Live PayoffCounters + bluePayoff scalar from uci-demo/copilot/score. */
  score: ScoreSnapshot | null;
  /** SubroutineTrace[] for the currently displayed proposal, keyed by planId. */
  doctrineTraces: Record<string, readonly SubroutineTraceLite[]>;

  setBelief: (trackId: string, snap: BeliefSnapshot) => void;
  clearBelief: (trackId: string) => void;
  setScore: (s: ScoreSnapshot) => void;
  setDoctrineTraces: (planId: string, traces: readonly SubroutineTraceLite[]) => void;
}

beliefHistory keeps the last 20 snapshots per track (matches the MAX_TRACK_HISTORY = 14 style cap). On clearBelief (EntityLost), the history is preserved so the TrackTimeline drawer can still render the post-mortem.

doctrineTraces is keyed by planId so multiple in-flight proposals don't collide. On engagement COMPLETE/FAILED/CANCELED, the entry remains until the proposal is dismissed.

The BeliefDistribution type duplicates the shape from @uci-demo/game/belief.ts. The UI cannot import from @uci-demo/game directly (see CLAUDE.md "two clients of the codec" section — the same browser-safe split applies). The belief type is trivially structural so the duplication is acceptable; we DO NOT import the validator's xmllint-wasm path indirectly.


Bus subscriber extensions (lib/busSubscriber.ts)

Add three dispatch branches after the existing uci-demo/world/roe handler at line ~575:

if (topic.startsWith("uci-demo/copilot/belief/")) {
  const trackId = topic.slice("uci-demo/copilot/belief/".length);
  // Empty payload = clear
  if (payload.length === 0) {
    store.clearBelief(trackId);
    return;
  }
  const json = JSON.parse(payload.toString()) as BeliefMessage;
  store.setBelief(trackId, {
    distribution: json.distribution,
    mostLikely: json.mostLikely,
    maxP: json.maxP,
    ts: json.ts,
  });
  return;
}

if (topic === "uci-demo/copilot/score") {
  const json = JSON.parse(payload.toString()) as ScoreMessage;
  store.setScore({ counters: json.counters, blueScore: json.blueScore, ts: json.ts });
  return;
}

if (topic.startsWith("uci-demo/copilot/doctrine/")) {
  const planId = topic.slice("uci-demo/copilot/doctrine/".length);
  const json = JSON.parse(payload.toString()) as DoctrineMessage;
  store.setDoctrineTraces(planId, json.traces);
  return;
}

The existing client.subscribe(["uci-demo/copilot/#", ...]) at line ~618 already covers the new sub-topics. No subscribe-list change required.

MessageBuffer (lib/messageBuffer.ts) needs to retain the new channels for replay — extend its allowlist to include uci-demo/copilot/belief/* and uci-demo/copilot/score. Doctrine traces are decision-tied and ephemeral, so they're excluded from the buffer (matches the existing exclusion of uci-demo/copilot/reason).


Component contracts

All components use the existing Tailwind v4 + @theme tokens from app/globals.css. The palette: --color-cyan (#7dd3fc) for primary phosphor, --color-amber (#fbbf24) for caution, --color-threat (#f87171) for hostile/danger, --color-grant (#34d399) for friendly, --color-violet (#a78bfa) for secondary signals. No new tokens; if a specific shade is needed, layer with color-mix().

BeliefBar.tsx

Slot: child of TrackLozenge, below the existing identity label.

Props:

interface BeliefBarProps {
  readonly belief: BeliefSnapshot | undefined;
  readonly compact?: boolean;  // small variant for map markers vs full track card
}

Visual:

TRACK-001                                             ◢◣
HOSTILE • severity=WARNING • range=2.1km                ◥◤
[██████████ HOSTILE 0.46 ░░░░░░░ SUSPECT 0.28 ▒▒▒▒ UNKNOWN 0.18 . ASF 0.04 ▴ NEUT 0.03]
  • 6 segments, widths proportional to distribution[id]
  • Color per bucket:
  • HOSTILE → --color-threat
  • SUSPECT → color-mix(in oklab, var(--color-threat) 60%, transparent)
  • UNKNOWN → --color-fg-dim
  • NEUTRAL → color-mix(in oklab, var(--color-cyan) 40%, transparent)
  • ASSUMED_FRIEND → color-mix(in oklab, var(--color-grant) 50%, transparent)
  • FRIEND → --color-grant
  • Sharp angular corners, 1px --color-rule-soft inner border
  • On hover: tooltip with all six percentages to 2dp
  • Fratricide-warn badge: when P(FRIEND) > 0.25, render a small ◬ FRIEND-RISK chip in --color-amber to the right of the bar. At P(FRIEND) > 0.4, escalate to --color-threat-hot and pulse (CSS @keyframes, 1s cycle, matches the existing alert-pulse animation).
  • compact variant: 24px wide stripe with no labels; appears as a thin under-ring on the TrackSymbol map marker.

ScoreHud.tsx

Slot: new component, mounted in TopStrip between scenarioName and the existing comms indicator. Alternative if TopStrip is too crowded: new BottomStrip component spanning the lower edge of the viewport above the engagement timeline.

Props:

interface ScoreHudProps {
  readonly score: ScoreSnapshot | null;
}

Visual:

┌──────────────────────────────────────────────────────────────┐
│ U_B = +1.683   ████████████████████░░░░  WINNING              │
│                                                                │
│  NEU 2   FRAT 0   ROE 1   FUEL 0.18   FAIL 1   DEG 23s  MTTD 2.4s │
└──────────────────────────────────────────────────────────────┘
  • Top row: U_B = +1.683 in segment-display style, color-shifted by sign: --color-grant for positive, --color-threat for negative, --color-amber for |U_B| < 0.2. Followed by a thin horizontal bar visualizing magnitude (clipped at ±5).
  • Bottom row: 7 micro-counters, monospace caps:
  • NEU / FRAT / ROE / FAIL — integer counts. FRAT > 0 forces the entire HUD into --color-threat-hot regardless of U_B sign (fratricide is the kill-shot metric).
  • FUEL — sum of effector-fraction-seconds, 2dp
  • DEG — seconds with dropPercent > 0, integer
  • MTTD — seconds rounded to 1dp
  • Hover any micro-counter → tooltip with the contribution to U_B (e.g. NEU 2 → +2.00, FRAT 0 → 0.00, FUEL 0.18 → -0.009).
  • Empty state (no score yet): U_B = —.—.— STANDBY

DoctrineStack.tsx

Slot: right rail (RightRail.tsx), between the existing approval card and the wire ticker. Renders only when a proposal is active (driven by state.proposal !== null). Subscribes to doctrineTraces[state.proposal.planId].

Props:

interface DoctrineStackProps {
  readonly planId: string | null;
  readonly traces: readonly SubroutineTraceLite[];
}

Visual — packet-trace decode aesthetic:

DOCTRINE ▸ PLAN-002
──────────────────────────────────────────────────
IDENTITY_GATE        [████████████░░░░░░░░] 0.61
  P(FRIEND)=0.52 on TRACK-001 — gate active, prefer withhold
  → WITHHOLD on TRACK-001

FRATRICIDE_AVOIDANCE [██████░░░░░░░░░░░░░░] 0.28
  P(FRIEND)=0.52 on TRACK-001 — soft fratricide guard, withhold
  → WITHHOLD on TRACK-001

ROE_ESCALATION       [█░░░░░░░░░░░░░░░░░░░] 0.06
  ROE=AMBER — no escalation

SOFTKILL_FIRST       [·░░░░░░░░░░░░░░░░░░░] 0.02  · dormant
REPLAN_ESCALATION    [·░░░░░░░░░░░░░░░░░░░] 0.01  · dormant
JAMMER_COUNTER       [·░░░░░░░░░░░░░░░░░░░] 0.01  · dormant
FUEL_CONSERVATION    [·░░░░░░░░░░░░░░░░░░░] 0.01  · dormant
COMMS_DEGRADE_HEDGE  [·░░░░░░░░░░░░░░░░░░░] 0.00  · dormant
  • 8 rows always rendered (even dormant), in canonical order matching createDefaultBank()'s declared order: IdentityGate, FratricideAvoidance, RoeEscalation, SoftKillFirst, ReplanEscalation, JammerCounter, FuelConservation, CommsDegradeHedge
  • Subroutine ID in --color-cyan mono caps when active (weight > 0.05), --color-fg-faint when dormant
  • Weight bar: 20-character ASCII bar, filled segments in --color-cyan-hot with phosphor glow, empty segments. Numeric weight to 2dp on the right.
  • Rationale line in --color-fg (active) or --color-fg-dim (dormant)
  • topAction chip (when present): → ACTION on TRACK-ID in --color-amber
  • Empty state (state.proposal === null): the panel is hidden
  • Updating: when a new trace burst arrives, the new active subroutines pulse for one cycle (200ms phosphor flicker)

TrackTimeline.tsx (drawer)

Slot: new full-height side drawer (left or right; left avoids collision with RightRail), opens on TrackLozenge or TrackSymbol click. Closes on Esc or backdrop click.

Props:

interface TrackTimelineProps {
  readonly trackId: string | null;  // null → closed
  readonly onClose: () => void;
}

The drawer aggregates events for the selected trackId from existing store fields + the new ones:

Source Event kind
state.tracks[trackId].detectedAt detected
state.wire filtered by trackId wire-msg (EntityNotification / Entity / PositionReport / EntityLost)
state.beliefHistory[trackId] belief-shift (when mostLikely or top decile changes)
state.intel filtered by relatedTrackId intel (existing reasoning lines)
state.proposal (when targeted at trackId) proposal
state.engagements filtered by trackId engagement-state (ACTIVE/COMPLETE/FAILED)
state.score deltas at trackId events payoff-contribution

Visual — vertical packet trace, newest at top:

TRACK-001 ▸ TIMELINE                                    [X]
══════════════════════════════════════════════════════════════
T+0:47.230  EntityLost                            -0.30 fuel
            ─→ +1.00 neutralized hostile          ◢ U_B+0.70
──────────────────────────────────────────────────────────────
T+0:46.118  EffectStatus  state=SUCCEEDED
            effector=GUARDIAN-3
──────────────────────────────────────────────────────────────
T+0:44.802  EffectStatus  state=FAILED            -0.30 fail
            effector=HAWK-2  effect=DISRUPT
            ─→ REPLAN_ESCALATION fires            ◬
──────────────────────────────────────────────────────────────
T+0:44.001  Proposal PLAN-002 → ENGAGE
            effect=DEFEAT_DESTROY effector=GUARDIAN-3
            doctrine: ReplanEscalation (0.71)
──────────────────────────────────────────────────────────────
T+0:43.500  Belief shift                          ◬
            P(HOSTILE) 0.46 → 0.71
            P(SUSPECT) 0.28 → 0.18
──────────────────────────────────────────────────────────────
T+0:42.100  EntityMT enrichment
            threatType=MISSILE  confidence=72%
──────────────────────────────────────────────────────────────
T+0:38.200  EntityNotification  severity=WARNING
            → identity inferred: HOSTILE (FIRST_CONTACT_PRIOR)
──────────────────────────────────────────────────────────────
T+0:38.000  detected                              ◐
──────────────────────────────────────────────────────────────
  • Timestamps relative to state.scenarioStartMs, format T+M:SS.mmm
  • Each event: timestamp, event-kind glyph, terse one-line summary, payoff delta column on the right when applicable
  • Glyphs (single mono characters, no emoji): detection, belief / doctrine, win, neutral
  • Color coding: neutralization in --color-grant, failure / fratricide in --color-threat, belief shifts in --color-cyan, doctrine fires in --color-amber
  • Footer of the drawer: TOTAL CONTRIBUTION U_B+0.40 (sum of payoff-contribution events for this track)
  • Performance: derived selector memoized on (trackId, wire.length, beliefHistory[trackId].length, engagements.length)

This is the highest-value Tier 1 surface — it is the single-click "why did the copilot do that?" answer. Every other Tier 1 component feeds into it.


Copilot-side publisher work (services/copilot/)

Belief mirror

The copilot's existing world-state ingestion at services/copilot/src/worldState.ts already routes EntityNotificationMT and EntityMT to a TrackSnapshot. Extend it:

// In services/copilot/src/worldState.ts (or a new beliefMirror.ts)
import {
  FIRST_CONTACT_PRIOR,
  type IdentityDistribution,
  updateIdentityBelief,
} from "@uci-demo/game";

const beliefs = new Map<string, IdentityDistribution>();

function onEntityNotification(topicId: string, severity: Severity) {
  const prior = beliefs.get(topicId) ?? FIRST_CONTACT_PRIOR;
  const post = updateIdentityBelief(prior, { severity });
  beliefs.set(topicId, post);
  publishBelief(topicId, post);
}

function onEntity(topicId: string, threatType?: string, conf?: number) {
  const prior = beliefs.get(topicId) ?? FIRST_CONTACT_PRIOR;
  const post = updateIdentityBelief(prior, { threatType, threatConf: conf });
  beliefs.set(topicId, post);
  publishBelief(topicId, post);
}

function onEntityLost(topicId: string) {
  beliefs.delete(topicId);
  publishClearBelief(topicId);  // empty payload, retained, clears the topic
}

@uci-demo/game becomes a workspace dep of services/copilot/. The existing worldState.ts re-export pointer to @uci-demo/game/worldMirror was already in the workspace plan but not landed; this PR makes the copilot a real consumer.

Payoff aggregator

A new module services/copilot/src/scoreMirror.ts:

import { type PayoffCounters, ZERO_PAYOFF_COUNTERS, bluePayoff } from "@uci-demo/game";

let counters: PayoffCounters = ZERO_PAYOFF_COUNTERS;
let publishTimer: NodeJS.Timeout | null = null;

function bump(delta: Partial<PayoffCounters>) {
  counters = {
    neutralizedHostiles: counters.neutralizedHostiles + (delta.neutralizedHostiles ?? 0),
    fratricideEvents: counters.fratricideEvents + (delta.fratricideEvents ?? 0),
    // ...
  };
  schedulePublish();
}

function schedulePublish() {
  if (publishTimer) return;
  publishTimer = setTimeout(() => {
    publishTimer = null;
    bus.publishRaw("uci-demo/copilot/score", JSON.stringify({
      schemaV: 1,
      counters,
      blueScore: bluePayoff(counters),
      ts: Date.now(),
      payoffV: PAYOFF_V,
    }), { retain: true });
  }, 1000);  // 1s debounce
}

// Hook into existing message handlers:
//   EntityLost → bump({ neutralizedHostiles | fratricideEvents })
//   EffectStatus FAILED → bump({ failedEffects })
//   EffectPlanCommand (kinetic under ROE GREEN) → bump({ roeViolations })
//   SubsystemStatus band drop → bump({ fuelBurnedTotal })
//   uci-demo/world/degrade interval → bump({ commsDegradeSeconds })
//   evaluate() wall-clock → running mean of meanTimeToDecisionMs

The fratricide-detection requires reading CapabilityCoverageAreaMT polygons (which the copilot subscribes to today) and a point-in-polygon test for the track's last PositionReportMT. The existing world-mirror has the polygons cached; add a small helper.

Doctrine publisher

When the copilot's agent (scriptedAgent or LLM-via-llmAgent) emits a proposal, also call the strategy bank to extract the trace:

// In services/copilot/src/main.ts at proposal-publish time
import { createDefaultBank, createRegretTable, buildGameState } from ".../* ... */";

// One-time setup
const bank = createDefaultBank();
const emptyRegret = createRegretTable(64);  // Phase 0: cold start, empty table

// Per-decision:
const gameState = buildGameState({ world: worldMirror.snapshot(), truth: EMPTY_SCENARIO_TRUTH });
const ctx = { info: gameState, viewer: "blue", legalActions: dynamics.legalActions(gameState) };
const traces = bank.explain(ctx, /* minWeight */ 0.05);

bus.publishRaw(`uci-demo/copilot/doctrine/${planId}`, JSON.stringify({
  schemaV: 1,
  planId,
  traces,
  ts: Date.now(),
}));

@uci-demo/solver becomes the second new workspace dep of services/copilot/. Crucially: the solver dep is only consumed for createDefaultBank() + createRegretTable() at Phase 0 cold start. The kernel (escfr.iterate) is not run from inside the copilot — that work belongs to solver-daemon (weeks 5-7).

Why cold-start is acceptable

With an empty regret table, bank.composedPolicy collapses to 0.6 * uniform + 0.4 * subroutine_prior — the CFR signal contributes nothing and the subroutine prior carries the doctrine. The traces emitted by bank.explain are still meaningful because each subroutine's weight and rationale are computed from the SubroutineContext (the game state), not from the regret table. The operator sees real doctrine reasoning; what changes when solver-daemon ships is which actions the policy mixes toward, weighted by trained regret.

This is the right Phase 0 shape — surface the interpretability layer now, even before the kernel is running live.


Module layout

apps/cop-ui/components/
├── BeliefBar.tsx              # NEW
├── ScoreHud.tsx               # NEW
├── DoctrineStack.tsx          # NEW
├── TrackTimeline.tsx          # NEW
├── TrackLozenge.tsx           # MODIFIED — embed BeliefBar
├── TrackSymbol.tsx            # MODIFIED — compact BeliefBar under-ring
├── TopStrip.tsx               # MODIFIED — slot ScoreHud
├── RightRail.tsx              # MODIFIED — slot DoctrineStack between approval card and wire
└── MapCanvas.tsx              # MODIFIED — wire trackId click → TrackTimeline open

apps/cop-ui/lib/
├── store.ts                   # MODIFIED — beliefs, beliefHistory, score, doctrineTraces fields + setters
├── busSubscriber.ts           # MODIFIED — three new topic dispatch branches
├── messageBuffer.ts           # MODIFIED — allowlist belief + score for replay
└── types.ts                   # MODIFIED — BeliefSnapshot, ScoreSnapshot, SubroutineTraceLite

services/copilot/
├── package.json               # MODIFIED — add @uci-demo/game + @uci-demo/solver
├── src/beliefMirror.ts        # NEW — wraps updateIdentityBelief, publishes per track
├── src/scoreMirror.ts         # NEW — PayoffCounters aggregator, debounced score publish
├── src/doctrinePublisher.ts   # NEW — createDefaultBank + bank.explain at proposal time
├── src/worldState.ts          # MODIFIED — hook the three publishers into existing dispatch
└── src/main.ts                # MODIFIED — initialize the three mirrors at startup

Total: 4 new UI components, 4 modified UI components, 3 modified UI library files, 3 new copilot modules, 1 modified copilot module, 1 modified copilot main + package.json. ~12 files new, ~9 modified.


Test plan

Unit tests (apps/cop-ui/components/<X>.test.tsx)

Add a vitest config to apps/cop-ui/ if absent (current state: only playwright tests). Tests:

  • BeliefBar renders 6 segments matching distribution; fratricide badge appears when P(FRIEND) > 0.25; pulses when > 0.4.
  • ScoreHud shows U_B = —.—.— on empty state; turns --color-grant when blueScore > 0.2; forces --color-threat-hot when fratricideEvents > 0 regardless of blueScore sign.
  • DoctrineStack renders 8 rows in canonical order; weight bars scale to 20 characters; dormant rows have --color-fg-faint.
  • TrackTimeline aggregates events from the store and orders newest first; payoff-delta column appears only for events that contribute.

Bus subscriber test (apps/cop-ui/lib/busSubscriber.test.ts)

Confirm the three new dispatch branches route to the correct setter.

Copilot publisher tests (services/copilot/test/)

  • beliefMirror.test.ts — feeds synthetic EntityNotification and Entity inputs; asserts the published belief matches updateIdentityBelief exactly.
  • scoreMirror.test.ts — feeds synthetic engagement events; asserts the published counters match.
  • doctrinePublisher.test.ts — instantiates a bank against an empty regret table; calls explain against a synthetic GameState; asserts the published JSON shape.

Manual / playwright

The existing playwright suite at apps/cop-ui/e2e/ runs against pnpm run up. Add three scenarios:

  1. Belief bar evolution: load Tripwire, wait 10s, screenshot the first track lozenge — expect a BeliefBar with HOSTILE mass > 0.4.
  2. Score HUD update: trigger an engagement, wait for EntityLost, screenshot the ScoreHud — expect NEU 1 and U_B > +0.5.
  3. Doctrine stack fires: with a proposal active, screenshot the RightRail — expect at least one subroutine row with weight > 0.1.

Acceptance gate

pnpm run up, open http://localhost:3000, watch Operation Tripwire for 30 seconds:

  • Every track lozenge shows a BeliefBar with non-zero mass
  • The ScoreHud's U_B updates within 1 second of an engagement
  • When a proposal appears, the DoctrineStack populates with ≥1 active subroutine
  • Clicking a track opens the TrackTimeline drawer showing the full event chronology including at least one belief shift
  • Validator audit (/audit?n=50) is still 100% valid — the side-channel additions do not pollute the UCI v2.5 traffic
  • All existing UI behaviors (proposal MODIFY, replay, comms degrade injection) still work

Aesthetic constraint reminders

Per CLAUDE.md "COP UI conventions": instrument-grade tactical brutalism, mono type, sharp angular geometry, corner brackets, phosphor glow on actives, scanline texture. Concretely:

  • No rounded-* Tailwind classes. Sharp corners only.
  • No emoji. Use unicode geometric characters (◢◣◤◥◬◐◯●○) or ASCII (▸◢[█░]) where a glyph is needed.
  • Mono font everywhere. The existing tactical font tokens apply.
  • Color is functional, not decorative. The semantic palette (cyan=info, amber=caution, threat=danger, grant=positive, violet=secondary) maps directly to the new components above. Don't introduce new colors.
  • Phosphor glow on actives. Existing CSS pattern at app/globals.css:107-127box-shadow: 0 0 6px <glow-token>, 0 0 1px <glow-token>.
  • Scanline texture continues behind all new panels (existing body-level ::before pattern at app/globals.css).

If a designer instinct tempts you toward gradients, soft shadows, or neumorphism — resist. The COP is an instrument, not a dashboard.


Acceptance criteria

  1. The 4 new UI components ship with vitest unit tests.
  2. The 3 new copilot publishers ship with vitest unit tests.
  3. The 3 new bus subscriber branches ship with one combined vitest case.
  4. Workspace pnpm -r typecheck and pnpm -r test remain clean.
  5. The manual acceptance gate above passes when the reviewer runs pnpm run up.
  6. Validator stays at 100% valid. The side-channel additions are uci-demo/... JSON; they do not touch the uci/v2_5/# schema-bound channels.
  7. BELIEF_V, PAYOFF_V constants from @uci-demo/game are embedded in the published JSON payloads (beliefV / payoffV keys), so a stale UI talking to a newer copilot can detect the mismatch.
  8. The COP UI does not import from @uci-demo/game or @uci-demo/solver. The validator's xmllint-wasm path must not reach the browser bundle. The structural duplication of BeliefDistribution in lib/types.ts is the explicit cost; the alternative (codec browser split) is deferred per BUILD.md Day 8.

What this memo deliberately does not specify

  • Solver-daemon status pill (SolverPill) — Tier 2, lands with solver-daemon in weeks 5-7.
  • Action policy bars (PolicyBars) — Tier 2, requires uci-demo/copilot/policy/<planId> from solver-daemon.
  • What-if simulator (WhatIfPanel) — Tier 3, post-Phase-0. Substrate (GameDynamics.apply / resolveChance) exists; the UI affordance and the in-browser simulation loop are research UX.
  • Multi-scenario A/B comparison page — Tier 3, lands with the eval-harness in weeks 7-9.
  • InfoSet debug pane — Tier 3, researcher UX.
  • Solver blueprint upload / training control surfaces — out of scope; the solver-daemon owns its own RPC contract.

Open questions to resolve before code lands

  1. ScoreHud placement: TopStrip vs new BottomStrip. The TopStrip is already dense (scenario name, ROE, comms, mute). Recommend BottomStrip — gives the score room to breathe and keeps the top for situational identity. Confirm during component review.
  2. TrackTimeline drawer side: left or right. Right collides with RightRail. Left is clean but the operator's eye is on the map center, so left-vs-right is a UX-fit call. Recommend left.
  3. BeliefBar on map markers (TrackSymbol): the compact variant adds visual complexity. Make it opt-in via a layer toggle in the TacticalOverlay panel — defaults off, operator can enable.
  4. Doctrine trace stability across re-renders: the bank's softmax is deterministic given identical input, but the React render cadence may not match the proposal cadence. Memoize the trace on (planId, gameStateInfoSetKey) so the panel doesn't flicker if the same proposal re-renders due to unrelated state changes.
  5. Empty-payload retain clear semantics: MQTT retain clears require publishing a zero-length payload with retain: true. Verify Mosquitto behavior matches this expectation in the playwright belief-clear test; some clients treat empty as "current retained value unchanged."

These are all knowable; none block landing.


Why this memo is shorter than the solver memo

The solver memo had to fix algorithm-correctness math (ES-MCCFR update, RM+, exploitability). This memo is mostly contracts: three JSON payloads, four component props, four files of copilot plumbing. The aesthetic constraint is already locked in CLAUDE.md.

The lesson from the prior workstreams: the long pages of the design memo earned their keep at review time. This memo follows the same shape — precise contracts up front — but commits less ink to material the previous memos already covered (workspace conventions, strict-TS discipline, MQTT topic split, brutalist aesthetic).