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)¶
BeliefBar— per-track 6-bucket Bayesian posterior stripeScoreHud— running PayoffCounters +bluePayoffheadlineDoctrineStack— 8-slot subroutine panel with weights + rationalesTrackTimeline— side-drawer per-track event chronology (the end-to-end interpretability artifact)
Plus the copilot-side publisher work that makes the data flow:
- Belief-state mirror in the copilot (uses
updateIdentityBelief) - Payoff-counter aggregator (subscribes to engagement events, maintains running PayoffCounters)
- Strategy-bank cold-start path (instantiate
createDefaultBank()against an emptyRegretTableso 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
EntityNotificationMTorEntityMTingestion. Computed byupdateIdentityBelief(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:
EntityLostMTfor HOSTILE/SUSPECT →neutralizedHostiles++EntityLostMTfor FRIEND inside anyCapabilityCoverageAreaMTpolygon →fratricideEvents++(loud and red in the HUD)EffectPlanCommandMTwith kinetic effect under ROE GREEN →roeViolations++EffectStatusMT.state === "FAILED"→failedEffects++SubsystemStatusMT.stateband drop → estimatedfuelBurnedTotalcontribution (LOW=0.05, CRITICAL=0.15 per drop)uci-demo/world/degradewindow integral →commsDegradeSeconds- Copilot's own
evaluate()wall-clock → running mean ofmeanTimeToDecisionMs 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 currentGameStateprojection. 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 withweight > 0.05are emitted (the bank's defaultminWeight).
Channels NOT in this memo (deferred)¶
uci-demo/solver/status— Tier 2, lands with solver-daemonuci-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-softinner border - On hover: tooltip with all six percentages to 2dp
- Fratricide-warn badge: when
P(FRIEND) > 0.25, render a small◬ FRIEND-RISKchip in--color-amberto the right of the bar. AtP(FRIEND) > 0.4, escalate to--color-threat-hotand pulse (CSS@keyframes, 1s cycle, matches the existing alert-pulse animation). compactvariant: 24px wide stripe with no labels; appears as a thin under-ring on theTrackSymbolmap 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.683in segment-display style, color-shifted by sign:--color-grantfor positive,--color-threatfor negative,--color-amberfor|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 > 0forces the entire HUD into--color-threat-hotregardless ofU_Bsign (fratricide is the kill-shot metric).FUEL— sum of effector-fraction-seconds, 2dpDEG— seconds withdropPercent > 0, integerMTTD— 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-cyanmono caps when active (weight > 0.05),--color-fg-faintwhen dormant - Weight bar: 20-character ASCII bar,
█filled segments in--color-cyan-hotwith phosphor glow,░empty segments. Numeric weight to 2dp on the right. - Rationale line in
--color-fg(active) or--color-fg-dim(dormant) topActionchip (when present):→ ACTION on TRACK-IDin--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, formatT+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:
BeliefBarrenders 6 segments matching distribution; fratricide badge appears whenP(FRIEND) > 0.25; pulses when> 0.4.ScoreHudshowsU_B = —.—.—on empty state; turns--color-grantwhenblueScore > 0.2; forces--color-threat-hotwhenfratricideEvents > 0regardless ofblueScoresign.DoctrineStackrenders 8 rows in canonical order; weight bars scale to 20 characters; dormant rows have--color-fg-faint.TrackTimelineaggregates 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 matchesupdateIdentityBeliefexactly.scoreMirror.test.ts— feeds synthetic engagement events; asserts the published counters match.doctrinePublisher.test.ts— instantiates a bank against an empty regret table; callsexplainagainst 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:
- Belief bar evolution: load Tripwire, wait 10s, screenshot the first track lozenge — expect a BeliefBar with HOSTILE mass > 0.4.
- Score HUD update: trigger an engagement, wait for EntityLost,
screenshot the ScoreHud — expect
NEU 1andU_B > +0.5. - 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_Bupdates 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-127—box-shadow: 0 0 6px <glow-token>, 0 0 1px <glow-token>. - Scanline texture continues behind all new panels (existing
body-level
::beforepattern atapp/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¶
- The 4 new UI components ship with vitest unit tests.
- The 3 new copilot publishers ship with vitest unit tests.
- The 3 new bus subscriber branches ship with one combined vitest case.
- Workspace
pnpm -r typecheckandpnpm -r testremain clean. - The manual acceptance gate above passes when the reviewer runs
pnpm run up. - Validator stays at 100% valid. The side-channel additions are
uci-demo/...JSON; they do not touch theuci/v2_5/#schema-bound channels. BELIEF_V,PAYOFF_Vconstants from@uci-demo/gameare embedded in the published JSON payloads (beliefV/payoffVkeys), so a stale UI talking to a newer copilot can detect the mismatch.- The COP UI does not import from
@uci-demo/gameor@uci-demo/solver. The validator's xmllint-wasm path must not reach the browser bundle. The structural duplication ofBeliefDistributioninlib/types.tsis the explicit cost; the alternative (codec browser split) is deferred perBUILD.mdDay 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, requiresuci-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¶
- 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.
- 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. - BeliefBar on map markers (
TrackSymbol): the compact variant adds visual complexity. Make it opt-in via a layer toggle in theTacticalOverlaypanel — defaults off, operator can enable. - 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. - 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).