The model layer of the project, built honestly:
- tools/dataset_validate.py — full-sweep validator over the receiver
store (sha256, schema, monotonic labels, telemetry-row gate). On the
current corpus: 64,798 accepted + 8,154 degraded + 3,701 rejected +
7 errored across 76,660 shipped episodes. data/processed/validation_v1.parquet
is committed as the per-episode acceptance index.
- training/_features.py — channel registry (46 channels across
proc/guest/qmp/netflow), summary-stat windowing AND channel×time
tensor extraction at 10s/5s windowing. Time alignment uses t_wall_ns
(Unix ns) — tested fix for a real netflow-vs-host clock-base
inconsistency that was silently dropping every netflow channel.
- training/_split.py — three held-out recipes (host / sample / time)
with profile-stratification assertions. held_out_host carries
untested_profiles for cases like scan-and-dial absent from the test
host (5 of 6 profiles tested cross-device, never silently averaged).
- training/models/ — 6 architectures behind a common BaseModel
interface: gbt (XGBoost), mlp, cnn, gru, lstm, transformer. Each
trained twice (realistic / oracle) per the deployment threat model.
Schema-hashed checkpoints refuse to load if _features.py changed
since training (silent-input-drift protection, tested).
- training/trainer/ — unified training loop: class-weighted CE, LR
warmup + cosine, gradient clipping, mixed precision when CUDA,
early stopping on val macro F1, best-on-val checkpoint. Same loop
runs MLP/CNN/GRU/LSTM/Transformer; GBT uses XGBoost
early_stopping_rounds on val mlogloss.
- training/eval_/ — bootstrap 95% CIs on macro F1, per-class F1,
per-profile and per-host breakdown, paired-bootstrap significance
for model-vs-model gap. Confusion matrix uses union of seen labels.
- training/dashboard/producers/ — replay/metrics/perf/profiles
emitting the six event types the dashboard's awaiting scenes
consume; on-demand tensor extraction so the Pi can run live
inference without 65 GB of shards.
- 17 unit tests (split coverage, features round-trip, schema mismatch,
determinism, time-base alignment regression).
End-to-end smoke-trained all six on a 567-episode subset; held-out
test macro F1 reported with paired-bootstrap significance. The
methodology now reports honest cross-device generalization, not
in-distribution validation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New endpoint GET /api/episode/<host_id>/<episode_id> in app.py.
Stream-decompresses the tarball (zstd -dc piped into tarfile),
extracts telemetry-proc.jsonl, labels.jsonl, and meta.json,
returns the parsed contents. Synchronous extract runs in
asyncio.to_thread so the event loop isn't blocked.
Frontend: clicking a row in the database explorer now fetches
the episode and draws an SVG chart matching the README's Real
Alpine VM envelope shape:
- per-interval CPU jiffies delta (user + sys)
- per-interval IO bytes delta (read + write)
- colored phase bands (clean/armed/infecting/infected_running/
dormant) overlaid by labels.jsonl
- axis ticks for 0-peak on Y, 0-totalDuration in seconds on X
- legend below the chart with palette-driven swatches
The detail panel that previously showed the row JSON now shows
metadata + the chart + the legend. Validated end-to-end against
a real episode (863 samples, 8 labels) extracted from
/var/lib/cis490/episodes/elliott-thinkpad/.
Prose's feathered left edge was overlapping with interactive
widgets in the metric stack and blocking clicks (the prose has
pointer-events: auto so its left half — even when visually
mostly transparent — still captured input).
Two changes:
- .scene right padding: 2rem → 0.5rem. Pushes the prose card
~1.5rem further right.
- .stage-view padding-right: was calc(prose-w - 1.5em), now just
prose-w. The metric-stack now ends exactly where the prose
column starts instead of being pushed 1.5em into prose
territory.
Result: roughly 3em of overlap reduction. The prose's left
feather and the metric-stack's right feather now meet over bg
rather than over each other's content.
Backdrop card was a sharp rectangle whose right edge butted hard
against the prose column's left feather, producing a visible
seam where the two layers met. Replaced the solid background
with a horizontal linear-gradient that fades at both edges:
0% → transparent (left edge dissolves into bg)
8% → full backdrop (card body begins)
78% → full backdrop (card body ends)
100% → transparent (right edge dissolves into bg)
The right fade is wider than the left because the right edge
overlaps the prose column's feathered start; double-feathering
that interface gives a continuous metric-card → bg → prose-card
transition with no rectangles meeting.
Border-radius removed — was hidden by the feather anyway.
New 'content backdrop' slider (0..1, default 0.30) in the
animation section of the theme panel. Drives a single CSS
variable --content-backdrop that controls a uniform dark layer
behind both:
- .metric-stack — solid background with that opacity, plus a
rounded corner so the metric content reads as a card sitting
over the bg.
- .scene .prose — added as a SECOND background layer underneath
the existing left-feathering gradient. The gradient stays;
where it's transparent (left edge), the new uniform layer
shows through. At backdrop=0 the prose looks identical to
before; at backdrop>0 the feathered edge reveals a partly-
opaque dark instead of fully transparent bg.
So when the bg is busy (vaporwave, drift, lava) the user can
crank backdrop up for legibility; when it's the still black
theme they can drop it to 0 for the cleanest look.
Confirmed by user that snapping scenes in/out instead of opacity-
transitioning fixed the grid-shape artifact that had been appearing
over metric content during scene changes.
Root cause: while stage-view's opacity animated between 0 and 1
(over 600ms), the compositor was rendering stage-view to its own
intermediate bitmap and sampling whatever was painted underneath
— including the bg-canvas's animated perspective grid. That
sampled grid leaked into the metric content area for the duration
of the transition. Removing the transition removes the compositor
work entirely; scenes change with a snap, no resampling.
Trade-off accepted: no fade between scenes. If a smoother
transition is wanted later, options that DON'T trigger the same
sampling are clip-path wipes, transform-based slides, or animating
opacity at <100ms (short enough that the sampled bitmap doesn't
have time to register visually).
The grid-shaped artifact that appeared over metric content
during scene transitions was Chromium promoting the stage-view
to its own compositor layer mid-transition (when opacity left
exactly 0). At that promotion moment the new layer samples
whatever's painted underneath as its initial bitmap — which is
the moving perspective grid in the bg — and that snapshot stays
visible for the duration of the 600ms opacity transition,
reading as a phantom grid pattern over the metric content.
will-change: opacity tells the browser to promote the layer
before the transition starts. The transition is then a pure
compositor opacity interpolation: no resampling of bg, no stale
snapshots. The hint is on the actual transitioning element
(stage-view), not on canvas-wrapper, which avoids the
cutout-mask issue from the previous over-aggressive layer
isolation attempts.
The 'rendering over presentation elements' artifacts were from
piling stacking-context-creating properties on bg-canvas:
- filter: blur(0px) — even at 0px creates a stacking context
AND a 3D-flattening grouping property
- transform: translateZ(0) — stacking context + 3D context
- earlier: isolation, contain — stacking context + flattening
Each of these on its own can cause neighboring element artifacts
in Chromium (cutout-shaped opacity transition leaks, or in extreme
cases content rendering at the wrong z-order).
Strip everything. The bg-canvas is now just position:fixed with
overflow:hidden — that's enough for the bg layers it contains,
and the browser will GPU-promote it automatically when there's
real animation inside. The filter is now conditional: JS sets
--bg-filter to blur(Npx) only when the slider is non-zero, and
removes the custom property at zero so the rule falls through to
filter: none and no stacking context is created.
The 'cutout mask' artifacts on foreground stage views came from
piling contain:paint + will-change + transform:translateZ all
together on .canvas-wrapper. Paint containment plus a
GPU-promoted layer on the foreground container breaks compositor
ordering when children's opacity transitions fire (the
IntersectionObserver fades stage-views in/out as scenes activate),
producing rectangular cutout-shaped artifacts where the
transitioning element's layer hadn't fully composited yet.
Strip canvas-wrapper back to the bare minimum (just position,
overflow, z-index). Also drop isolation/contain from bg-canvas,
keeping only transform: translateZ(0) for layer promotion — that
alone is enough to give bg-canvas its own compositor layer
without fighting the foreground's painting.
The scanlines were genuinely the source of the orthogonal-in-3D
artifact, in two compounding ways:
1. mix-blend-mode: multiply on a fullscreen overlay forces an
isolation group: the browser composites the 3D-rotated floor
into a flat 2D bitmap underneath the blend, and that
flattening interacts badly with how Chromium rasterizes
perspective transforms.
2. The 4px stripe period beats against the perspective floor's
per-row line spacing (which is dense near the horizon and
sparse near the viewer). At the screen y where the floor's
row-spacing crosses 4px, the patterns interfere — producing
moiré bands that look like a phantom grid orthogonal to the
floor plane.
Fix: confine scanlines to the sky region above the horizon
(they never touch the perspective grid), drop the multiply
blend (regular alpha compositing), and use a 5px period that
avoids resonance with anything else in the scene.
The 'lines orthogonal in 3D space' artifact was the intro scene's
.bg-grid — a flat 2D grid pattern in canvas-wrapper (z=1), drawn
on screen with horizontal + vertical 1px lines. While the user
was on the intro scene with vaporwave active, this 2D grid
overlaid the rotated perspective floor, looking exactly like a
phantom wall of grid lines orthogonal to the floor — same kind of
pattern, similar palette, but on a plane perpendicular to it.
.bg-grid was added for visual texture under the black theme; the
animated themes (drift/lava/vaporwave/laser) all have their own
backgrounds and don't need it. Hide it on any non-black theme.
The audit-trail of changes I made trying to find this in .vw-floor
have all been valid (the perspective-origin at horizon, the
collapse to a single transform, dropping will-change) and all
should stay — they removed real layer-promotion concerns. But the
artifact the user was seeing the whole time was this overlay.
Audit revealed the orthogonal-in-3D phantom was actually correct
rendering of an INCORRECT perspective setup. Default
perspective-origin is 50% 50% of the perspective container — but
.vw-floor extends from the horizon (top 55%) to bottom -10%, so its
midpoint sits ~82% from the viewport top, well below the horizon
line. Receding vertical grid lines were converging at that midpoint
instead of at the horizon, which visually reads as the grid lines
standing UP off the floor at varying angles — exactly the "lines
literally orthogonal in 3D space" artifact.
Setting perspective-origin: 50% 0 puts the vanishing point at the
top of the floor box, which is exactly the horizon line, so
verticals converge where they should and the floor stops looking
3D-broken.
Removing the .vw-floor-tilt wrapper. The previous nested structure
(perspective container → rotated tilt with preserve-3d → grid with
animated translate3d) had three transform layers that the
compositor had to keep in sync, and Chromium's optimizer kept
breaking the agreement — placing the grid's compositor layer flat
in 3D space, producing the phantom orthogonal post.
New structure: one perspective container, one animated grid. The
rotateX and translateY live on the SAME transform property on the
SAME element. Both keyframes carry the same rotateX so it doesn't
animate (only translateY interpolates), and the rotation is part
of the same transform list as the translate so the grid never
leaves its rotated plane.
No will-change, no transform-style: preserve-3d, no
separately-promoted compositor layer. Nothing for the compositor
to disagree about.
Audit traced the 'orthogonal in 3D space' artifact to
will-change: transform on .vw-floor-grid. Chromium's compositor
was rasterizing the grid's gradient into its own layer and
placing the resulting bitmap in 3D using only the element's
own translate3d, ignoring the parent's rotateX even though
the parent had transform-style: preserve-3d. The result was
a flat 2D billboard of the grid pattern appearing as an
upright post alongside the correctly-rotated floor grid.
Removing will-change lets the simpler layer-promotion path
fire (animation alone), which respects preserve-3d. Also
add transform-style: preserve-3d on the grid itself for
belt-and-suspenders.
The 'orthogonal in 3D space' artifact is the animated grid layer being
rendered flat (its own 2D bitmap) and then composited back into the
rotated parent — visually appearing as a phantom grid orthogonal to
the actual floor plane. transform-style: preserve-3d on .vw-floor-tilt
tells the compositor to keep the child transforms in the parent's 3D
context so the grid stays glued to the floor instead of popping out.
The earlier canvas→divs revert edit silently dropped due to a
file-modified race; HTML still had <canvas class=vw-floor-canvas>
while the CSS had been rewritten to target .vw-floor/.vw-floor-tilt/
.vw-floor-grid. Result: rules matched nothing and the grid was
invisible. This commit restores the div tree the CSS expects.
User prefers the CSS perspective look — restore the rotateX +
translate-only-animation grid. To finally kill the scroll-flicker,
push much harder on layer isolation:
- bg-canvas: isolation, contain: layout style paint, transform:
translateZ(0), will-change: transform, overflow-anchor: none
- canvas-wrapper: same compositor-layer treatment so its
data-active scene transitions can't dirty bg-canvas
- vw-floor: contain: strict + transform: translateZ(0)
- vw-floor-tilt: rotateX combined with translateZ(0) (hint
to compositor that this is a 3D layer)
- vw-floor-grid: backface-visibility: hidden, will-change
- html/body/article: overflow-anchor: none (disables Chrome's
scroll anchoring, which can nudge layout during the IO-driven
scene toggle and trigger re-paints)
- Lines bumped 2px → 3px (less subpixel sensitivity)
The canvas-based floor is removed; vw-floor-canvas + the rAF
drawing loop are gone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
<canvas> is a replaced element — top/bottom positioning alone
doesn't size it; it keeps its intrinsic 300x150 dimensions until
you give it explicit CSS width AND height. The previous CSS only
set width:100% so the canvas rendered at default 300x150, ignoring
bottom:0 and producing a tiny mis-positioned grid.
Set both explicitly: width: 100%, height: calc(100% - --vw-horizon).
Add a ResizeObserver so the drawing buffer follows the box when
the user moves the horizon slider or the window resizes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two prior CSS attempts both flickered when the user scrolled the
page. The first animated background-position on a perspective-rotated
element; the second split rotate from translate onto separate
elements with isolation: isolate, will-change, contain: paint, etc.
The browser kept recomputing the perspective math in lockstep with
document scroll regardless.
Replace the floor with a <canvas> drawn frame-by-frame via rAF:
- No CSS perspective transform; lines drawn at explicit pixel
positions by JS, so subpixel sampling can't shift them between
frames.
- Half-pixel y offsets keep 2px lines crisp.
- Perspective via 1/(1 + z*k) falloff formula; the slider
(perspective 30..80) drives k.
- Palette colors read from `var(--c1)` / `var(--c2)` via a hidden
probe div (lets the browser do the OKLCH→rgb conversion).
Cache refreshed every time the palette changes.
- Rendering loop early-returns when the active theme isn't
vaporwave, so it costs nothing on other themes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Even though bg-canvas is position:fixed, the browser was
re-rasterizing the perspective-rotated grid as the document
scrolled, producing visible jumps in the line positions.
Force bg-canvas into its own compositor layer (isolation: isolate
+ will-change: transform), confine the floor's paint to its box
(contain: paint), and drop transform-style: preserve-3d from the
floor-tilt (it's not needed without nested 3D transforms and
seems to encourage extra rasterization on Chromium).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Theme panel
- Was a small floating tooltip-style card near the topbar.
- Now a full-height sidebar pinned to the right half of the screen
(50vw, 100vw on narrow viewports). Slides in/out from the right
via a transform transition. Toggle via the `is-open` class
instead of the hidden attribute so the animation works on close
too. Sticky header keeps the title and close button in view as
the panel scrolls.
No-despawn-while-on-screen
- Settings sliders that previously rebuilt the bg elements via
innerHTML='' caused all visible bubbles/blobs/beams to vanish
instantly. Now existing elements are retired gracefully:
- drift / lava: retireOnIteration — wait for the element's next
animationiteration event (which fires at the end of one rise
cycle, when the element is offscreen) and remove then. The
user sees old bubbles complete their journey while new ones
spawn alongside.
- laser: fadeOutAndRemove — opacity transitions to 0 over 800ms
then the element is removed. Lasers have no offscreen moment
in their continuous rotation so a fade is the only graceful
option.
- 60s safety timeout on retireOnIteration so spamming a slider
doesn't leak old nodes if animationiteration somehow doesn't
fire.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Animating background-position on a perspective-rotated element
re-rasterizes the gradient every frame; with 1px lines under
3D transform, subpixel sampling jitter caused them to flip
on/off between frames.
Fix: split rotate (static) and translate (animated) onto separate
elements, animate transform: translate3d instead, GPU-promote the
grid layer (will-change + translateZ + backface-visibility), and
bump line width to 2px. The repeating-linear-gradient is drawn
once and the layer offset moves it; translating by exactly one
cell-period loops seamlessly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-theme settings panel
- New section in the theme panel that auto-shows the active theme's
knobs (and only the active theme). Built dynamically from a
THEMES spec; new params drop in by adding one entry.
- drift: blob count (3-12), size, blur, opacity
- lava: bubble count (4-16), mean size, σ spread, goo merge
strength, goo blur σ. Bubbles are now JS-generated with sizes
drawn from N(mean, σ) so the spread slider produces real
variance, not just one-size-fits-all.
- vaporwave: grid cell, horizon angle, sun size, horizon position,
blind width.
- laser: beam count, thickness, blur, opacity. Beams JS-generated.
Vaporwave overhaul (was: a single perspective grid + radial sun)
- Layered scene: gradient sky → palette-blended sun with venetian-
blind stripes on the lower half → glowing horizon line → palette
perspective floor → CRT-style scanline overlay
- Sun box-shadow gives a halo; horizon has multi-stop glow
Live SVG goo filter
- The lava merge strength / blur sliders now mutate the inline
<feGaussianBlur stdDeviation> and <feColorMatrix values>
attributes via JS (CSS vars don't work inside SVG filter primitives).
Settings persist in localStorage; new params are merged in over
older snapshots so prior reloads don't break.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the discrete harmony dropdown with two continuous sliders
that together cover every named scheme:
- count (1..6) — number of palette colors
- spread (0..300°) — total angular range across the palette
Offsets fan symmetrically from the primary, so n=3 spread=60 →
[0, +30, -30] and n=4 spread=270 → [0, +90, -90, +180]. Common
named harmonies fall out as specific (count, spread) values:
mono count=1, any spread
complementary count=2, spread=180
analogous count=3-5, spread<=60
split-complementary count=3, spread~180-210
triadic count=3, spread=240
tetradic / square count=4, spread=270
A small hint line under the sliders shows the matched harmony name
when (count, spread) lands in a recognized neighborhood, or "custom"
otherwise. Per-marker drags continue to work on top of the
continuous setting; touching the count or spread slider regenerates
to the symmetric distribution (i.e. drags are reset on slider use,
which is the natural "clean me up" action).
Migration: any prior localStorage state with the old discrete
`harmony` field or multiplicative `spread` is silently dropped at
boot and replaced with the new defaults (count=3, spread=60).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes
- Wheel markers are now actually draggable. The previous version
rebuilt the marker DOM during drag, which destroyed the captured
element. apply() now only rebuilds when harmony changes; otherwise
it updates marker positions in place.
- Background swap is verified end-to-end. body[data-theme] is set
by JS and the CSS selectors gate per-theme bg layers correctly.
New theme controls (advanced details panes)
- spread (0.2-2x): scales harmony angular offsets uniformly
- L variance (0-40): per-color alternating lightness ladder
- C variance (0-0.15): per-color alternating chroma ladder
- animation speed (0.1-4x): drives every animation-duration via
calc(N / var(--anim-speed))
- background blur (0-40px): filter: blur on the entire bg-canvas
- tint strength (0-0.6): palette-tinted vignette opacity, applied
on EVERY theme so even "black" picks up palette character
Marker drag semantics
- Primary marker drag → rotates the entire palette (changes H)
- Non-primary marker drag → moves just that marker's offset
(allows individual proportion adjustment on top of harmony preset)
Backgrounds
- Old "lava" is renamed "drift" (soft blurred-blob version)
- New "lava" is a proper lava lamp via SVG goo filter
(Gaussian blur + alpha threshold) so bubbles merge as they
approach. Eight palette-colored bubbles rising at staggered
speeds.
State persists in localStorage; all sliders + offsets survive
reload. Panel resets all knobs back to defaults.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Press `t` (or click the topbar button) to open a floating theme
panel. Choices persist in localStorage.
Color machinery
- All palette colors derived from one base OKLCH (L, C, H) plus a
harmony rule that fixes angular offsets between siblings. Six
rules: mono, analogous, complementary, triadic, tetradic,
split-complement. Browsers parse CSS oklch() natively so no JS
conversion math is needed.
- Sliders for L (20-95%), C (0-0.4), H (0-360°), plus an interactive
color wheel where each marker is draggable. Dragging any marker
rotates the entire palette around the wheel — proportions are
locked by the harmony rule, so siblings move together.
Background themes (--c1..c5 from the palette drive every color)
- black: solid bg, no animation. Default.
- lava: 6 blurred radial blobs floating up the screen at different
speeds, screen-blended.
- vaporwave: perspective grid crawling toward the viewer + a soft
radial sun in palette colors.
- laser: 5 long beams sweeping from screen center, palette-colored.
Caveats
- oklch() needs Chrome 111+ / Firefox 113+ / Safari 15.4+. Older
browsers will fall back to CSS init values (which match the
previous palette), so the page still renders.
- Phase colors (clean/armed/etc) stay hardcoded — they're semantic.
- The keydown listener for `t` is registered alongside the other
hotkeys and skips form fields like the rest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- New scene 10 between Sequence models and KNN: shows
training/models/lstm.py shape (PyTorch LSTM trainer, ~25 lines).
Placeholder content; the model session can swap in the real
trainer string in dashboard.js when it lands.
- Sharpens the prose gradient (95% solid until 70% across instead
of 92% solid until 55%) and bumps stage-view right-padding by
2.5em, so the prose column has a clearer edge against the canvas
content instead of fading smoothly into it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audience takeaway is supposed to be that everything they're seeing is
genuine live data from the devices. The library list moves to a
single supporting paragraph instead of dominating the slide.
Slots in between intro and collect: a side-by-side display of
pyproject.toml's annotated dependency list and the import header of
receiver/app.py. The point is to surface the project's
stdlib-first / annotated-deps stance early in the deck without
making the audience open a terminal.
Lightweight syntax highlighting via a small char-by-char Python
tokenizer (no third-party highlight.js). The TOML case uses regex.
GitHub-dark color palette.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Documents the event contract for producers that want to drive
widgets on the dashboard:
- /publish endpoint (loopback only; Caddy 404s externally)
- All six widget-driving event types and their shapes
- The reconnect gotcha (live events not replayed; only `snapshot` is)
- Two integration patterns (separate process vs in-process)
- Three options for browser-triggered demos
- systemd hardening that constrains producers running on the Pi
Adds a stdlib-only Publisher helper so producers don't need to
hand-roll urllib.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Starlette + WebSocket dashboard run on the Pi as cis490-dashboard.service
(127.0.0.1:8447, Caddy-fronted at dashboard.wg). Tails
/var/lib/cis490/index.jsonl for episode events, snapshots host counts
every 30s, broadcasts to every connected browser. New connections get a
warm snapshot (recent_episodes, total_bytes, host_counts) so reloads
don't see a cold dashboard.
Frontend is a 10-scene scrollytelling deck following the project
outline: intro, collect, hosts, db explorer, baseline, attacks,
chunking, models, knn, perf. Sticky full-bleed canvas with a
right-aligned prose column (matrix-explorable layout). Hotkeys (arrows,
space, j/k, c, Home/End), prev/next chevrons, FAB, and an opt-in
click-to-advance toggle. Demo toggle drives synthetic data for the
five scenes that have no real producer yet (attack envelopes,
chunking, model bars, knn scatter, perf scatter); when off, those
scenes show "awaiting <event_type> events" rather than fake data.
Producers wire in by POSTing typed JSON to 127.0.0.1:8447/publish
(loopback only; Caddy 404s it externally). Event types the widgets
subscribe to: model_metric {model, accuracy}, embedding {x, y, phase},
model_perf {model, latency_us, accuracy}, prediction {episode_id,
window_idx, predicted, actual}, attack_profile {name, shape, curve},
phase {phase}.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>