Replace the SVG 2-D scatter with a canvas-based 3-D one. Three color modes (phase / predicted / cluster) with a toggle; drag the surface to rotate; reset button. Bounding cube draws faintly so the rotation reads as 3-D rather than re-shuffled 2-D. Embedding event gains optional z / predicted / cluster fields. 2-D producers still work (z defaults to 0.5, no other behavior changes). CSS adds .scatter3d-* rules; --theme-h-num exposed for cluster-color hue arithmetic. Synthetic demo data is now 3-D Gaussian clusters with ~7% mislabeled "predictions" so the predicted-mode view differs from ground truth at a glance. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
298 lines
13 KiB
Python
298 lines
13 KiB
Python
"""Typed event interface for the dashboard's interactive scenes.
|
||
|
||
This module is the producer-facing contract for everything the
|
||
dashboard frontend can render. Each ``@dataclass`` below is one event
|
||
the bus carries; the docstrings spell out which scene (panel) it
|
||
drives, what shape it expects, and which UI behavior it produces.
|
||
|
||
Two ways to publish:
|
||
|
||
1. **Out-of-process** (typical) — your producer code lives in its
|
||
own service / script, posts events to ``/publish`` over loopback::
|
||
|
||
from training.dashboard.events import Publisher, ModelMetric
|
||
|
||
pub = Publisher()
|
||
pub.publish(ModelMetric(model="lstm", accuracy=0.928))
|
||
|
||
``Publisher.publish`` accepts either a dataclass from this module
|
||
or a plain ``dict`` shaped the same way.
|
||
|
||
2. **In-process** — your code is imported into the dashboard's own
|
||
uvicorn process. Skip the HTTP round-trip and call the broadcaster
|
||
directly::
|
||
|
||
from training.dashboard.app import broadcaster
|
||
from training.dashboard.events import ModelMetric
|
||
|
||
await broadcaster.publish(ModelMetric("lstm", 0.928).to_event())
|
||
|
||
Mind the systemd hardening — see ``PRODUCERS.md`` for the
|
||
read-only-fs / writable-paths story.
|
||
|
||
Scene → event(s) it consumes
|
||
============================
|
||
|
||
========================== ==================================================
|
||
Scene (in deck order) Event type(s)
|
||
========================== ==================================================
|
||
2. stack (static; no events)
|
||
3. collect ``snapshot``, ``episode`` (already wired by feeder)
|
||
4. hosts ``snapshot``, ``episode`` (already wired by feeder)
|
||
5. db ``snapshot`` (already wired by feeder)
|
||
6. baseline ``phase`` → :class:`PhaseEvent`
|
||
7. attacks ``attack_profile`` → :class:`AttackProfile`
|
||
8. chunking ``prediction`` → :class:`Prediction`
|
||
9. models ``model_metric`` → :class:`ModelMetric`
|
||
10. training-code (static; no events)
|
||
11. knn ``embedding`` → :class:`Embedding`
|
||
12. perf ``model_perf`` → :class:`ModelPerf`
|
||
========================== ==================================================
|
||
|
||
The reconnect gotcha
|
||
====================
|
||
|
||
Live events go *only* to currently-connected browsers. Reconnects don't
|
||
replay them. ``snapshot`` is the only event the dashboard re-sends to
|
||
new browsers (sourced from disk by the feeder, not your producer).
|
||
|
||
If you want a value to stick across reconnects, **republish on a tick**
|
||
(every 10–30 s is plenty). For any event type where this matters, file
|
||
a request and we'll add per-type sticky caching to the broadcaster.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass, field
|
||
from typing import Any, Literal, Optional, Sequence
|
||
|
||
from .client import Publisher
|
||
|
||
# ─────────────────────────────────────────────────────────────────────
|
||
# Vocabulary — Literal types so static checkers + IDEs autocomplete.
|
||
# Both are intentionally narrow: producers should pick from these
|
||
# rather than invent new strings, otherwise the corresponding
|
||
# widget can't paint them with the right palette colors.
|
||
# ─────────────────────────────────────────────────────────────────────
|
||
|
||
Phase = Literal[
|
||
"clean",
|
||
"armed",
|
||
"infecting",
|
||
"infected_running",
|
||
"dormant",
|
||
]
|
||
|
||
Model = Literal[
|
||
"rnn",
|
||
"gru",
|
||
"lstm",
|
||
"bert",
|
||
"knn",
|
||
]
|
||
|
||
|
||
# Base helper so every event has a uniform ``.to_event()`` that drops
|
||
# ``None`` fields (so optional values don't pollute the wire payload
|
||
# with explicit nulls when the producer doesn't have them).
|
||
class _EventBase:
|
||
type: str
|
||
|
||
def to_event(self) -> dict[str, Any]:
|
||
out: dict[str, Any] = {"type": self.type}
|
||
for k, v in self.__dict__.items():
|
||
if v is None:
|
||
continue
|
||
out[k] = list(v) if isinstance(v, tuple) else v
|
||
return out
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────
|
||
# Phase — scene 6 (baseline)
|
||
# ─────────────────────────────────────────────────────────────────────
|
||
|
||
@dataclass
|
||
class PhaseEvent(_EventBase):
|
||
"""One phase transition observed by the orchestrator.
|
||
|
||
The widget aggregates events into a rolling 5-minute window and
|
||
paints the proportion of each phase as a stacked horizontal bar.
|
||
Emit one whenever a labelled phase boundary is crossed (or
|
||
periodically on a tick if you want to seed the mix from a frozen
|
||
label).
|
||
|
||
:param phase: One of the canonical :data:`Phase` values.
|
||
"""
|
||
phase: Phase
|
||
type: str = field(default="phase", init=False, repr=False)
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────
|
||
# AttackProfile — scene 7 (attacks)
|
||
# ─────────────────────────────────────────────────────────────────────
|
||
|
||
@dataclass
|
||
class AttackProfile(_EventBase):
|
||
"""A canonical envelope thumbnail for one malware profile.
|
||
|
||
The widget renders a small sparkline per profile, normalised to
|
||
its own peak. Send one event per profile on startup; updates
|
||
overwrite the prior thumbnail with the same name.
|
||
|
||
:param name: Profile slug (e.g. ``cpu-saturate``, ``bursty-c2``).
|
||
:param curve: Sequence of values; rescaled to 0–1 internally.
|
||
~80 samples renders best at the thumbnail's pixel size.
|
||
:param shape: Optional one-line description shown under the name
|
||
(e.g. ``sustained 1-vCPU peg``).
|
||
"""
|
||
name: str
|
||
curve: Sequence[float]
|
||
shape: str = ""
|
||
type: str = field(default="attack_profile", init=False, repr=False)
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────
|
||
# Prediction — scene 8 (chunking)
|
||
# ─────────────────────────────────────────────────────────────────────
|
||
|
||
@dataclass
|
||
class Prediction(_EventBase):
|
||
"""A model's per-window prediction inside one episode.
|
||
|
||
The widget shows a six-cell timeline of 10-second windows; an
|
||
event repaints the cell at ``window_idx`` with ``predicted`` (or
|
||
``actual`` if predicted is omitted).
|
||
|
||
:param episode_id: Source episode (informational; multiple
|
||
episodes' predictions overwrite the same cells).
|
||
:param window_idx: 0–5; out-of-range values are ignored.
|
||
:param predicted: Model's guess for the window-centre phase.
|
||
:param actual: Ground-truth phase from ``labels.jsonl``.
|
||
"""
|
||
episode_id: str
|
||
window_idx: int
|
||
predicted: Optional[Phase] = None
|
||
actual: Optional[Phase] = None
|
||
type: str = field(default="prediction", init=False, repr=False)
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────
|
||
# ModelMetric — scene 9 (models)
|
||
# ─────────────────────────────────────────────────────────────────────
|
||
|
||
@dataclass
|
||
class ModelMetric(_EventBase):
|
||
"""Held-out accuracy for one sequence model.
|
||
|
||
The widget paints one horizontal bar per model name; the most
|
||
recent event for a given ``model`` overwrites that bar.
|
||
|
||
:param model: One of :data:`Model`. Other strings work but won't
|
||
get a colored fill class without a CSS update.
|
||
:param accuracy: 0–1. Stretched against a 0.5–1.0 visible scale
|
||
so cluster-around-0.9 differences show clearly.
|
||
"""
|
||
model: Model
|
||
accuracy: float
|
||
type: str = field(default="model_metric", init=False, repr=False)
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────
|
||
# Embedding — scene 11 (knn)
|
||
# ─────────────────────────────────────────────────────────────────────
|
||
|
||
@dataclass
|
||
class Embedding(_EventBase):
|
||
"""One projected feature vector for the KNN 3-D scatter plot.
|
||
|
||
Each event adds a single dot at ``(x, y, z)``. The scene has a
|
||
mode toggle that picks which categorical field colors the dot:
|
||
``phase`` (ground truth), ``predicted`` (the KNN/ML output), or
|
||
``cluster`` (an unsupervised cluster id).
|
||
|
||
Run your projector (PCA / UMAP / t-SNE in 3 components) on the
|
||
per-window engineered features, rescale each axis to 0–1, and
|
||
emit one event per point.
|
||
|
||
:param x: 0–1, mapped to the plot's first projected axis.
|
||
:param y: 0–1, mapped to the plot's second projected axis.
|
||
:param z: 0–1, mapped to the plot's third projected axis.
|
||
Optional; omit for a 2-D event (renders at z=0.5).
|
||
:param phase: Ground-truth phase. Color when mode = ``phase``.
|
||
:param predicted: Model's predicted phase. Color when mode =
|
||
``predicted``. Must be a canonical :data:`Phase`
|
||
string for the swatches to match.
|
||
:param cluster: Unsupervised cluster id (any non-negative int).
|
||
Color when mode = ``cluster``; the legend
|
||
auto-builds palette swatches per id.
|
||
"""
|
||
x: float
|
||
y: float
|
||
z: Optional[float] = None
|
||
phase: Optional[Phase] = None
|
||
predicted: Optional[Phase] = None
|
||
cluster: Optional[int] = None
|
||
type: str = field(default="embedding", init=False, repr=False)
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────
|
||
# ModelPerf — scene 12 (perf)
|
||
# ─────────────────────────────────────────────────────────────────────
|
||
|
||
@dataclass
|
||
class ModelPerf(_EventBase):
|
||
"""One point on the accuracy-vs-inference-cost scatter.
|
||
|
||
Each model name maps to a single point; subsequent events update
|
||
that point's position. The label next to the dot is the model name.
|
||
|
||
:param model: One of :data:`Model`.
|
||
:param latency_us: Inference cost in microseconds per window.
|
||
Plotted on the X axis (lower-left is better).
|
||
:param accuracy: Held-out accuracy 0–1, plotted on the Y axis.
|
||
"""
|
||
model: Model
|
||
latency_us: float
|
||
accuracy: float
|
||
type: str = field(default="model_perf", init=False, repr=False)
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────
|
||
# Convenience publish helpers — typed wrappers over the HTTP client.
|
||
# ─────────────────────────────────────────────────────────────────────
|
||
|
||
EventLike = _EventBase | dict[str, Any]
|
||
|
||
|
||
def publish(event: EventLike, *, publisher: Optional[Publisher] = None) -> dict[str, Any]:
|
||
"""Send one event via a default :class:`Publisher`.
|
||
|
||
Pass either a dataclass from this module or a plain dict. Returns
|
||
the parsed response (e.g. ``{"delivered": 2}``). On HTTP error,
|
||
raises ``urllib.error.HTTPError`` — wrap as appropriate.
|
||
"""
|
||
pub = publisher or Publisher()
|
||
payload = event.to_event() if isinstance(event, _EventBase) else event
|
||
return pub.publish(payload)
|
||
|
||
|
||
def try_publish(event: EventLike, *, publisher: Optional[Publisher] = None) -> int:
|
||
"""Fire-and-forget variant of :func:`publish` — swallows errors
|
||
and returns the delivered count (0 on failure)."""
|
||
pub = publisher or Publisher()
|
||
payload = event.to_event() if isinstance(event, _EventBase) else event
|
||
return pub.try_publish(payload)
|
||
|
||
|
||
__all__ = [
|
||
"Phase",
|
||
"Model",
|
||
"PhaseEvent",
|
||
"AttackProfile",
|
||
"Prediction",
|
||
"ModelMetric",
|
||
"Embedding",
|
||
"ModelPerf",
|
||
"Publisher",
|
||
"publish",
|
||
"try_publish",
|
||
]
|