Skip to content

Commit 4734bc8

Browse files
committed
Observatory: rich rendering, evolution replay, QD atlas, live decision feed
Four surfaces on top of the Provenance work: Rendering (frontend/src/render/): shiki-highlighted code, collapsible JSON tree, GFM markdown, CSV table previews, ANSI-stripped logs — wired into the replay inspector, workspace previews, and the artifacts tab, each with a raw toggle. Evolution tab: the family replayed as an animated SVG lineage (reveal per tick, play/step/speed/scrub, best-so-far badge chasing the frontier, ancestry glow) with a per-node narration panel — score delta vs parent, claims, QD/novelty, cost, selection log, and the textual gradient. Served by GET /runs/{id}/evolution. Atlas (/atlas, GET /atlas/{space}): every run PCA-projected with lineage trails, colour modes, family filter, fleet time-replay, zoom/pan, and a per-family point-by-point trajectory mode. Two spaces: qd (Mimosa's behaviour descriptor — measured byte-identical within a family, i.e. task-level, families coincide by construction) and genotype (TF-IDF of the evolved workflow code, 113 runs, where within-family drift is real). The trajectory bar says so and offers the switch when a family's points coincide. Live: the watcher now covers workflows, memory, runs_capsule and evaluations roots and emits step_appended / llm_call_logged / gradient_updated / evaluation_updated / astra_updated / evaluation_capsule_updated. The run page auto-refreshes affected panels and shows a live feed that diffs the ASTRA decision layer on every capsule update. Backend tests 57 -> 67; tsc, oxlint, vite build clean; new deps shiki/react-markdown/remark-gfm/diff (frontend), numpy (backend).
1 parent b9cf5a8 commit 4734bc8

30 files changed

Lines changed: 4236 additions & 96 deletions

webui/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,34 @@ For every evolution run under `sources/workflows/<uuid>/`:
4747
judge layer, which is never merged into the executor score. Directories are
4848
overridable via `MIMOSA_CAPSULE_DIR` / `MIMOSA_EVAL_DIR` (defaults:
4949
`runs_capsule/`, `evaluations/`).
50+
- **Evolution** — the family's search replayed as an animated lineage: nodes
51+
reveal in evolution order (play/pause/step/speed/scrub), the best-so-far
52+
badge chases the frontier, and the info panel narrates each run — score with
53+
delta vs parent, claim counts, QD/novelty, cost, the selection log, and the
54+
textual gradient that steered the next mutation
55+
(`GET /api/runs/{id}/evolution` joins all of it per node).
5056
- **Artifacts** — a raw browser over every file in the run dir.
5157

58+
Everywhere text is shown, content renders by type (`frontend/src/render/`):
59+
shiki syntax highlighting for code, a collapsible JSON tree, GFM markdown,
60+
CSV/TSV table previews, ANSI-stripped logs — each with a raw toggle.
61+
62+
Two cross-run surfaces:
63+
64+
- **QD Atlas** (`/atlas`, `GET /api/atlas/{space}`) — every run PCA-projected
65+
to 2D with parent→child trails, colour by score/family/iteration, family
66+
filter, fleet time-replay, zoom/pan, and a per-family **trajectory mode**
67+
that steps point-by-point along a comet trail. Two spaces: `qd` (Mimosa's
68+
384-dim behaviour descriptor — task-level, so one family's runs coincide)
69+
and `genotype` (TF-IDF of the evolved workflow code, where within-family
70+
drift is visible).
71+
- **Live activity** — the backend watches all four artifact roots
72+
(workflows, memory, `runs_capsule`, evaluations) and streams semantic
73+
events over the existing `/api/live` WebSocket (steps appended, gradient
74+
written, ASTRA capsule updated, …). The run page shows a live feed that
75+
diffs the ASTRA decision layer on every capsule update and auto-refreshes
76+
the workspace and provenance panels.
77+
5278
Plus two pages that replace the CLI onboarding:
5379

5480
- **Setup** — API-key status and entry (values written to the same dotenv files

webui/backend/app/atlas.py

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
"""Project every run's QD behaviour descriptor onto a 2-D atlas.
2+
3+
Mimosa's Quality-Diversity search already embeds each evolved workflow as a
4+
384-dim behaviour descriptor (``run_metrics.json`` → ``qd_descriptor``) — the
5+
space the evolution engine itself explores. The atlas is a PCA projection of
6+
those descriptors: each run becomes a point, parent→child links become trails,
7+
and the picture is literally "where the search went", not an ad-hoc embedding
8+
invented for display.
9+
10+
Runs without a descriptor (38 of 93 at the time of writing: crashed runs and
11+
pre-QD snapshots) are reported in ``skipped`` rather than silently dropped.
12+
Everything follows store.py's defensive contract — missing or malformed
13+
metrics yield an empty atlas, never an exception.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import math
19+
import re
20+
from collections import Counter
21+
from typing import Any
22+
23+
import numpy as np
24+
25+
from . import lineage, store
26+
27+
# A projection fitted on fewer points than this is geometry-free noise; the
28+
# frontend shows a "not enough embedded runs" note instead.
29+
MIN_POINTS = 3
30+
31+
32+
def _components(x: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
33+
"""Centred data, first two principal axes, and explained-variance ratios."""
34+
centred = x - x.mean(axis=0)
35+
# SVD-based PCA: stable for n_samples << n_features (55 runs x 384 dims).
36+
_, s, vt = np.linalg.svd(centred, full_matrices=False)
37+
var = s**2
38+
total = float(var.sum()) or 1.0
39+
return centred, vt[:2], var[:2] / total
40+
41+
42+
def compute_atlas(rows: list[dict[str, Any]]) -> dict[str, Any]:
43+
"""The atlas for a set of run rows.
44+
45+
Each row carries at least ``id`` and ``qd_descriptor``; ``score``,
46+
``iteration``, ``evolution_kind``, ``parents``, ``cost``, ``family`` and
47+
``started_at`` are passed through onto the projected point when present.
48+
Rows whose descriptor is missing, empty, or of a deviant dimensionality
49+
are skipped (listed with a reason) — one malformed run must not sink the
50+
picture.
51+
"""
52+
usable: list[dict[str, Any]] = []
53+
skipped: list[dict[str, str]] = []
54+
dims: dict[int, int] = {}
55+
for r in rows:
56+
q = r.get("qd_descriptor")
57+
if not isinstance(q, list) or not q:
58+
skipped.append({"id": str(r.get("id")), "reason": "no_descriptor"})
59+
continue
60+
dims[len(q)] = dims.get(len(q), 0) + 1
61+
usable.append(r)
62+
63+
if usable:
64+
# The QD space has one native dimensionality; a stray vector of any
65+
# other length is a corrupt record, not a second space.
66+
native = max(dims, key=lambda d: dims[d])
67+
kept = []
68+
for r in usable:
69+
if len(r["qd_descriptor"]) == native:
70+
kept.append(r)
71+
else:
72+
skipped.append({"id": str(r.get("id")), "reason": "dimension_mismatch"})
73+
usable = kept
74+
75+
if len(usable) < MIN_POINTS:
76+
return {"points": [], "edges": [], "skipped": skipped,
77+
"variance_explained": [], "n_dimensions": 0}
78+
79+
x = np.asarray([r["qd_descriptor"] for r in usable], dtype=np.float64)
80+
centred, axes, ratios = _components(x)
81+
xy = centred @ axes.T
82+
83+
ids = {str(r["id"]) for r in usable}
84+
points = []
85+
for r, (px, py) in zip(usable, xy):
86+
points.append({
87+
"id": str(r["id"]),
88+
"x": round(float(px), 5),
89+
"y": round(float(py), 5),
90+
"score": r.get("score"),
91+
"iteration": r.get("iteration"),
92+
"evolution_kind": r.get("evolution_kind"),
93+
"family": r.get("family"),
94+
"started_at": r.get("started_at"),
95+
"cost": r.get("cost"),
96+
})
97+
# Trails only between points that are both on the map.
98+
edges = [
99+
{"source": str(p), "target": str(r["id"])}
100+
for r in usable
101+
for p in (r.get("parents") or [])
102+
if str(p) in ids
103+
]
104+
return {
105+
"points": points,
106+
"edges": edges,
107+
"skipped": skipped,
108+
"variance_explained": [round(float(v), 4) for v in ratios],
109+
"n_dimensions": int(x.shape[1]),
110+
}
111+
112+
113+
_TOKEN = re.compile(r"[A-Za-z_]{2,}")
114+
115+
116+
def tfidf_vectors(texts: list[str]) -> list[list[float]]:
117+
"""L2-normalised TF-IDF over word/identifier tokens, one row per text.
118+
119+
Exists because the QD behaviour descriptor turns out to embed the TASK,
120+
not the evolved workflow: within a family every mutation carries a
121+
byte-identical vector, so a family's trajectory through QD space has no
122+
extent by construction. The genotype space projects the evolved CODE
123+
instead — within-family drift is real there.
124+
"""
125+
docs = [_TOKEN.findall(t.lower()) for t in texts]
126+
df: Counter[str] = Counter()
127+
tfs: list[Counter[str]] = []
128+
for toks in docs:
129+
c = Counter(toks)
130+
tfs.append(c)
131+
df.update(set(toks))
132+
vocab = sorted(df)
133+
idx = {w: i for i, w in enumerate(vocab)}
134+
n = len(docs)
135+
mat = np.zeros((n, len(vocab)), dtype=np.float64)
136+
for r, counts in enumerate(tfs):
137+
total = sum(counts.values()) or 1
138+
for w, k in counts.items():
139+
mat[r, idx[w]] = (k / total) * (math.log((1 + n) / (1 + df[w])) + 1.0)
140+
norms = np.linalg.norm(mat, axis=1, keepdims=True)
141+
norms[norms == 0] = 1.0
142+
return (mat / norms).tolist()
143+
144+
145+
def _genotype_text(run_id: str) -> str | None:
146+
path = store._find(store.run_path(run_id), "workflow_genotype_*.py")
147+
return store._read_text(path) if path else None
148+
149+
150+
def load_rows() -> list[dict[str, Any]]:
151+
"""One atlas row per run on disk, descriptor included when it exists."""
152+
fam = lineage.families()
153+
rows: list[dict[str, Any]] = []
154+
for run_id in store.list_run_ids():
155+
metrics = store.read_run_metrics(run_id) or {}
156+
rec = store.read_lineage(run_id) or {}
157+
rows.append({
158+
"id": run_id,
159+
"qd_descriptor": store.read_qd_descriptor(run_id),
160+
"score": store.overall_score(run_id, metrics or None),
161+
"iteration": rec.get("iteration", metrics.get("iteration")),
162+
"evolution_kind": rec.get("evolution_kind", metrics.get("evolution_kind") or "seed"),
163+
"parents": [p for p in rec.get("parents", []) if isinstance(p, str)],
164+
"family": fam.get(run_id),
165+
"started_at": rec.get("created_at") or store.parse_created_at(run_id),
166+
"cost": metrics.get("iteration_cost_usd"),
167+
})
168+
return rows
169+
170+
171+
def atlas_view(space: str = "qd") -> dict[str, Any]:
172+
"""The full-fleet atlas for one embedding space.
173+
174+
``qd``: Mimosa's own 384-dim behaviour descriptor (task-level — families
175+
coincide). ``genotype``: TF-IDF of each run's evolved workflow code
176+
(within-family drift visible). Same payload shape either way.
177+
"""
178+
rows = load_rows()
179+
if space == "genotype":
180+
texts: list[str] = []
181+
with_code: list[dict[str, Any]] = []
182+
for r in rows:
183+
text = _genotype_text(r["id"])
184+
if text:
185+
texts.append(text)
186+
with_code.append(r)
187+
else:
188+
r["qd_descriptor"] = None
189+
if with_code:
190+
for r, vec in zip(with_code, tfidf_vectors(texts)):
191+
r["qd_descriptor"] = vec
192+
return compute_atlas(rows)

webui/backend/app/evolution.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""One payload for the animated evolution replay: the family, fully annotated.
2+
3+
The desktop animator (workflow_evolution_anim.py) reads only lineage +
4+
evaluation.txt and leaves the most telling signals on the floor: cost, QD /
5+
novelty scores, the selection log, and the textual gradient that steered each
6+
mutation. This view joins all of it per family member so the frontend can
7+
narrate the evolution — which run mutated from which, steered by what
8+
feedback, at what cost, with what outcome — without a request per node.
9+
10+
Follows store.py's defensive contract: absent files yield ``None`` fields,
11+
never an exception.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
from typing import Any
17+
18+
from . import lineage, store
19+
20+
GRADIENT_SNIPPET_CHARS = 800
21+
22+
23+
def _claim_counts(run_id: str) -> dict[str, int] | None:
24+
claims = store.read_evaluation_claims(run_id)
25+
if claims is None:
26+
return None
27+
counts = {"passed": 0, "failed": 0, "error": 0, "unsure": 0}
28+
for c in claims:
29+
status = str(c.get("status", ""))
30+
key = {"pass": "passed", "fail": "failed"}.get(status, status)
31+
if key in counts:
32+
counts[key] += 1
33+
return counts
34+
35+
36+
def _gradient_snippet(run_id: str) -> str | None:
37+
text = store._read_text(store.run_path(run_id) / "textual_gradient.txt")
38+
if not text:
39+
return None
40+
text = text.strip()
41+
if len(text) > GRADIENT_SNIPPET_CHARS:
42+
return text[:GRADIENT_SNIPPET_CHARS].rstrip() + " …"
43+
return text
44+
45+
46+
def family_evolution(run_id: str) -> dict[str, Any] | None:
47+
"""Tree + per-node metrics/claims/gradient for *run_id*'s family."""
48+
tree = lineage.tree(run_id)
49+
if tree is None:
50+
return None
51+
idx_parents = {
52+
e["target"]: [] for e in tree["edges"]
53+
}
54+
for e in tree["edges"]:
55+
idx_parents[e["target"]].append(e["source"])
56+
57+
nodes = []
58+
for n in tree["nodes"]:
59+
uuid = n["id"]
60+
metrics = store.read_run_metrics(uuid) or {}
61+
selection = metrics.get("selection_log")
62+
selection = selection if isinstance(selection, dict) else {}
63+
nodes.append({
64+
**n,
65+
"parents": idx_parents.get(uuid, []),
66+
"score_uncapped": metrics.get("overall_score_uncapped"),
67+
"qd_score": metrics.get("qd_score"),
68+
"novelty_score": metrics.get("novelty_score"),
69+
"iteration_cost_usd": metrics.get("iteration_cost_usd"),
70+
"cumulative_cost_usd": metrics.get("cumulative_cost_usd"),
71+
"wall_time_s": metrics.get("iteration_wall_time_s"),
72+
"on_error": metrics.get("on_error"),
73+
"claims": _claim_counts(uuid),
74+
"gradient_snippet": _gradient_snippet(uuid),
75+
"selection": {
76+
"improvement_type": selection.get("improvement_type"),
77+
"delta_reward": selection.get("delta_reward"),
78+
"is_validated": selection.get("is_validated"),
79+
"confidence": selection.get("confidence"),
80+
"admit_rejected": selection.get("admit_rejected"),
81+
} if selection else None,
82+
})
83+
return {"focus": run_id, "nodes": nodes, "edges": tree["edges"]}

webui/backend/app/lineage.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,20 @@ def series(run_id: str) -> dict[str, Any]:
122122
return {"focus": run_id, "points": points}
123123

124124

125+
def families() -> dict[str, int]:
126+
"""uuid → family label for every run, one label per connected component."""
127+
idx = _index()
128+
fam: dict[str, int] = {}
129+
label = 0
130+
for uuid in idx:
131+
if uuid in fam:
132+
continue
133+
for member in _component(uuid, idx):
134+
fam[member] = label
135+
label += 1
136+
return fam
137+
138+
125139
def qd_archive(limit: int | None = None) -> list[dict[str, Any]]:
126140
"""Parse the append-only shared QD archive (drops the 384-float vector)."""
127141
path: Path = get_settings().workflow_dir / "qd_archive.jsonl"

0 commit comments

Comments
 (0)