web1 checkpoint

This commit is contained in:
Michael Pilosov 2026-04-21 19:04:23 -06:00
parent 261da42c00
commit 22ca411210
6 changed files with 1332 additions and 0 deletions

0
app/web1/__init__.py Normal file
View File

507
app/web1/main.py Normal file
View File

@ -0,0 +1,507 @@
"""
web1 "Scientific instrument / research notebook"
A FastAPI UI for kicking off the embedding-flow Prefect deployment and
viewing the resulting HTML animations.
Design: restrained, typography-driven, two-column notebook layout. No CSS
framework; hand-written styles.
"""
from __future__ import annotations
import importlib.util
import json
import os
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import httpx
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
# ---------------------------------------------------------------------------
# Paths / constants
# ---------------------------------------------------------------------------
BASE_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = BASE_DIR.parent.parent # /home/mm/work/dr-sandbox
FIGS_DIR = PROJECT_ROOT / "figs"
FIGS_DIR.mkdir(parents=True, exist_ok=True)
PREFECT_API = os.environ.get("PREFECT_API_URL", "http://localhost:4200/api")
DEPLOYMENT_NAME = "embedding-flow/embedding-flow"
GENERATOR_OPTIONS = [
("sklearn.datasets.make_s_curve", "make_s_curve"),
("sklearn.datasets.make_swiss_roll", "make_swiss_roll"),
("sklearn.datasets.make_blobs", "make_blobs"),
]
# ---------------------------------------------------------------------------
# Reducer catalogue
# ---------------------------------------------------------------------------
# Each field tuple: (name, kind, default, choices_or_none, help_or_none)
# kinds: "int", "float", "str", "bool", "str_or_float", "int_or_null"
REDUCERS: Dict[str, Dict[str, Any]] = {
"sklearn.decomposition.PCA": {
"pkg": "sklearn",
"label": "PCA",
"blurb": "Principal component analysis. Linear, fast, deterministic.",
"key": [
("n_components", "int", 2, None, "Locked to 2 — flow asserts 2D output."),
],
"advanced": [
("svd_solver", "str", "auto", ["auto", "full", "arpack", "randomized"], None),
("random_state", "int", 42, None, None),
("whiten", "bool", False, None, None),
],
},
"sklearn.decomposition.FactorAnalysis": {
"pkg": "sklearn",
"label": "FactorAnalysis",
"blurb": "Gaussian latent-factor model with per-feature noise.",
"key": [
("n_components", "int", 2, None, "Locked to 2 — flow asserts 2D output."),
("random_state", "int", 42, None, None),
],
"advanced": [
("tol", "float", 0.01, None, None),
("max_iter", "int", 1000, None, None),
("rotation", "str", "", ["", "varimax", "quartimax"], "Empty = None."),
],
},
"sklearn.manifold.TSNE": {
"pkg": "sklearn",
"label": "t-SNE",
"blurb": "Stochastic neighbour embedding. Local structure preserved.",
"key": [
("n_components", "int", 2, None, "Locked to 2 — flow asserts 2D output."),
("perplexity", "float", 30.0, None, None),
("random_state", "int", 42, None, None),
],
"advanced": [
("learning_rate", "str_or_float", "auto", None, "'auto' or a float."),
("n_iter", "int", 1000, None, None),
("metric", "str", "euclidean", None, None),
("early_exaggeration", "float", 12.0, None, None),
("init", "str", "pca", ["pca", "random"], None),
],
},
"umap.UMAP": {
"pkg": "umap",
"label": "UMAP",
"blurb": "Uniform manifold approximation. Preserves local + some global structure.",
"key": [
("n_components", "int", 2, None, "Locked to 2 — flow asserts 2D output."),
("n_neighbors", "int", 15, None, None),
("min_dist", "float", 0.1, None, None),
("random_state", "int", 42, None, None),
],
"advanced": [
("metric", "str", "euclidean", None, None),
("n_epochs", "int_or_null", "", None, "Empty = None (auto)."),
("spread", "float", 1.0, None, None),
("init", "str", "spectral", ["spectral", "random"], None),
],
},
"pacmap.PaCMAP": {
"pkg": "pacmap",
"label": "PaCMAP",
"blurb": "Pairwise-controlled manifold approximation. Balanced local/global.",
"key": [
("n_components", "int", 2, None, "Locked to 2 — flow asserts 2D output."),
("n_neighbors", "int", 10, None, None),
("MN_ratio", "float", 0.5, None, None),
("FP_ratio", "float", 2.0, None, None),
("random_state", "int", 42, None, None),
],
"advanced": [
("lr", "float", 1.0, None, None),
("num_iters", "int", 450, None, None),
("apply_pca", "bool", True, None, None),
],
},
"trimap.TRIMAP": {
"pkg": "trimap",
"label": "TriMap",
"blurb": "Triplet-based dimensionality reduction. Emphasises global structure.",
"key": [
("n_dims", "int", 2, None, "Locked to 2 — flow asserts 2D output."),
("n_inliers", "int", 10, None, None),
("n_outliers", "int", 5, None, None),
("n_random", "int", 5, None, None),
],
"advanced": [
("lr", "float", 0.1, None, None),
("n_iters", "int", 400, None, None),
("weight_adj", "float", 500.0, None, None),
],
},
}
def available_reducers() -> List[Tuple[str, Dict[str, Any]]]:
out = []
for key, spec in REDUCERS.items():
if importlib.util.find_spec(spec["pkg"]) is not None:
out.append((key, spec))
return out
# ---------------------------------------------------------------------------
# Parameter coercion
# ---------------------------------------------------------------------------
def _coerce(kind: str, raw: Optional[str], default: Any) -> Any:
if raw is None:
return default
s = raw.strip() if isinstance(raw, str) else raw
if kind == "int":
if s == "" or s is None:
return default
return int(s)
if kind == "float":
if s == "" or s is None:
return default
return float(s)
if kind == "bool":
# Checkbox: "on" / absent
return bool(s) and s not in ("0", "false", "False", "")
if kind == "str":
if s == "":
return None if default in (None, "") else default if default else ""
return s
if kind == "str_or_float":
if s == "":
return default
try:
return float(s)
except (ValueError, TypeError):
return s
if kind == "int_or_null":
if s == "":
return None
return int(s)
return s
def build_embed_args(reducer_key: str, form: Dict[str, str]) -> Dict[str, Any]:
spec = REDUCERS[reducer_key]
out: Dict[str, Any] = {}
all_fields = list(spec["key"]) + list(spec["advanced"])
for (name, kind, default, _choices, _help) in all_fields:
raw = form.get(f"embed__{name}")
if kind == "bool":
raw_v = "on" if f"embed__{name}" in form else ""
value = bool(raw_v)
else:
value = _coerce(kind, raw, default)
# Null-stripping: drop empty rotations etc.
if value is None:
continue
if isinstance(value, str) and value == "" and default in (None, ""):
continue
out[name] = value
# Always force n_components / n_dims to 2 (flow assertion)
if "n_components" in out:
out["n_components"] = 2
if "n_dims" in out:
out["n_dims"] = 2
return out
# ---------------------------------------------------------------------------
# Output-path synthesis (mirrors flows/embedding_flow.py lines ~162168)
# ---------------------------------------------------------------------------
def synthesize_output_paths(
generator_path: str,
embedder: str,
num_points: int,
num_snapshots: int,
jitter_scale: float,
seed: int,
) -> Tuple[str, str]:
gen = generator_path.split(".")[-1]
emb = embedder.split(".")[-1]
ref = f"{gen}_Reference_N{num_points}_S{num_snapshots}_J{jitter_scale}_s{seed}.html"
embf = f"{gen}_{emb}_N{num_points}_S{num_snapshots}_J{jitter_scale}_s{seed}.html"
return ref, embf
# ---------------------------------------------------------------------------
# Prefect client
# ---------------------------------------------------------------------------
class Prefect:
def __init__(self, base: str = PREFECT_API) -> None:
self.base = base.rstrip("/")
self._deployment_id: Optional[str] = None
async def deployment_id(self, client: httpx.AsyncClient) -> Optional[str]:
if self._deployment_id:
return self._deployment_id
try:
r = await client.get(f"{self.base}/deployments/name/{DEPLOYMENT_NAME}")
if r.status_code == 200:
self._deployment_id = r.json()["id"]
return self._deployment_id
except httpx.HTTPError:
return None
return None
async def create_run(
self, client: httpx.AsyncClient, parameters: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
dep = await self.deployment_id(client)
if not dep:
return None
r = await client.post(
f"{self.base}/deployments/{dep}/create_flow_run",
json={"parameters": parameters},
)
if r.status_code >= 400:
return {"error": r.text, "status": r.status_code}
return r.json()
async def recent_runs(
self, client: httpx.AsyncClient, limit: int = 10
) -> List[Dict[str, Any]]:
dep = await self.deployment_id(client)
if not dep:
return []
try:
r = await client.post(
f"{self.base}/flow_runs/filter",
json={
"sort": "START_TIME_DESC",
"limit": limit,
"flow_runs": {"deployment_id": {"any_": [dep]}},
},
)
if r.status_code == 200:
return r.json()
except httpx.HTTPError:
return []
return []
PREFECT = Prefect()
# ---------------------------------------------------------------------------
# In-memory mapping: flow_run_id -> synthesized output file names
# (best-effort; lost on restart, which is fine)
# ---------------------------------------------------------------------------
RUN_OUTPUTS: Dict[str, Dict[str, str]] = {}
# ---------------------------------------------------------------------------
# App
# ---------------------------------------------------------------------------
app = FastAPI(title="web1 — embedding notebook", docs_url=None, redoc_url=None)
app.mount("/figs", StaticFiles(directory=str(FIGS_DIR)), name="figs")
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
def _run_view(run: Dict[str, Any]) -> Dict[str, Any]:
"""Normalise a flow-run dict for the template."""
rid = run.get("id", "")
state_type = (run.get("state_type") or "PENDING").upper()
state_name = run.get("state_name") or state_type.title()
start = run.get("start_time") or run.get("expected_start_time") or run.get("created")
params = run.get("parameters") or {}
# Try to look up synthesised outputs either from memory or from params
ref_file = None
emb_file = None
outs = RUN_OUTPUTS.get(rid)
if outs:
ref_file = outs["ref"]
emb_file = outs["embed"]
elif params:
try:
ref_file, emb_file = synthesize_output_paths(
params.get("generator_path", "sklearn.datasets.make_s_curve"),
params.get("embedder", "sklearn.decomposition.FactorAnalysis"),
int(params.get("num_points", 5000)),
int(params.get("num_snapshots", 48)),
float(params.get("jitter_scale", 0.01)),
int(params.get("seed", 42)),
)
except Exception:
ref_file, emb_file = None, None
ref_exists = bool(ref_file) and (FIGS_DIR / ref_file).exists()
emb_exists = bool(emb_file) and (FIGS_DIR / emb_file).exists()
return {
"id": rid,
"short_id": rid[:8] if rid else "",
"name": run.get("name", ""),
"state_type": state_type,
"state_name": state_name,
"start": start,
"params": params,
"ref_file": ref_file,
"emb_file": emb_file,
"ref_exists": ref_exists,
"emb_exists": emb_exists,
"embedder_short": (params.get("embedder") or "").split(".")[-1],
"generator_short": (params.get("generator_path") or "").split(".")[-1],
}
def _reducer_choices() -> List[Dict[str, str]]:
return [
{"key": k, "label": spec["label"], "blurb": spec["blurb"]}
for k, spec in available_reducers()
]
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse:
reducers = _reducer_choices()
default_reducer = reducers[0]["key"] if reducers else None
default_spec = REDUCERS.get(default_reducer) if default_reducer else None
async with httpx.AsyncClient(timeout=5.0) as client:
runs = await PREFECT.recent_runs(client, limit=10)
dep_id = await PREFECT.deployment_id(client)
views = [_run_view(r) for r in runs]
return templates.TemplateResponse(
request,
"index.html",
{
"reducers": reducers,
"default_reducer": default_reducer,
"default_spec": default_spec,
"generators": GENERATOR_OPTIONS,
"runs": views,
"deployment_id": dep_id,
"prefect_api": PREFECT_API,
},
)
@app.get("/reducer-form", response_class=HTMLResponse)
async def reducer_form(request: Request, name: str) -> HTMLResponse:
spec = REDUCERS.get(name)
if not spec:
return HTMLResponse("<p class='err'>unknown reducer</p>", status_code=404)
return templates.TemplateResponse(
request,
"_reducer_form.html",
{"reducer_key": name, "spec": spec},
)
@app.get("/runs", response_class=HTMLResponse)
async def runs_partial(request: Request) -> HTMLResponse:
async with httpx.AsyncClient(timeout=5.0) as client:
runs = await PREFECT.recent_runs(client, limit=10)
views = [_run_view(r) for r in runs]
return templates.TemplateResponse(
request, "_runs.html", {"runs": views}
)
@app.post("/submit", response_class=HTMLResponse)
async def submit(request: Request) -> HTMLResponse:
form = await request.form()
data: Dict[str, str] = {k: str(v) for k, v in form.items()}
reducer = data.get("reducer") or ""
if reducer not in REDUCERS:
return HTMLResponse(
f"<div class='flash err'>unknown reducer: {reducer}</div>",
status_code=400,
)
# Data params
try:
num_points = int(data.get("num_points", "5000") or 5000)
num_snapshots = int(data.get("num_snapshots", "48") or 48)
jitter_scale = float(data.get("jitter_scale", "0.01") or 0.01)
seed = int(data.get("seed", "42") or 42)
except ValueError as e:
return HTMLResponse(
f"<div class='flash err'>bad numeric input: {e}</div>", status_code=400
)
generator_path = data.get("generator_path") or "sklearn.datasets.make_s_curve"
embed_args = build_embed_args(reducer, data)
generator_kwargs: Dict[str, Any] = {}
if generator_path.endswith("make_blobs"):
generator_kwargs["n_features"] = 3
parameters: Dict[str, Any] = {
"num_points": num_points,
"num_snapshots": num_snapshots,
"jitter_scale": jitter_scale,
"seed": seed,
"generator_path": generator_path,
"embedder": reducer,
"embed_args": embed_args,
}
if generator_kwargs:
parameters["generator_kwargs"] = generator_kwargs
async with httpx.AsyncClient(timeout=10.0) as client:
run = await PREFECT.create_run(client, parameters)
if not run:
return HTMLResponse(
"<div class='flash err'>could not reach Prefect API at "
f"{PREFECT_API}</div>",
status_code=502,
)
if "error" in run:
return HTMLResponse(
f"<div class='flash err'>prefect error ({run.get('status')}): "
f"<code>{run.get('error')[:500]}</code></div>",
status_code=502,
)
ref_file, emb_file = synthesize_output_paths(
generator_path, reducer, num_points, num_snapshots, jitter_scale, seed
)
RUN_OUTPUTS[run["id"]] = {"ref": ref_file, "embed": emb_file}
# Return freshly refreshed runs partial so htmx can swap the right column
async with httpx.AsyncClient(timeout=5.0) as client:
runs = await PREFECT.recent_runs(client, limit=10)
views = [_run_view(r) for r in runs]
return templates.TemplateResponse(
request,
"_runs.html",
{"runs": views, "just_submitted": run["id"]},
)
@app.get("/health")
async def health() -> JSONResponse:
async with httpx.AsyncClient(timeout=3.0) as client:
dep = await PREFECT.deployment_id(client)
return JSONResponse(
{"ok": True, "deployment_id": dep, "prefect_api": PREFECT_API}
)

515
app/web1/static/style.css Normal file
View File

@ -0,0 +1,515 @@
/* ---------------------------------------------------------------------------
* web1 scientific instrument / research notebook
*
* Palette:
* page #fafaf8 off-white
* ink #1a1a1a charcoal
* mute #6b6b68 secondary text
* faint #b9b8b2 hairlines, disabled
* rule #e3e1db dividers
* accent #1f4e5f muted deep teal
* alarm #8a3a2a rust, for failures (sparingly)
* ------------------------------------------------------------------------- */
:root {
--page: #fafaf8;
--panel: #ffffff;
--ink: #1a1a1a;
--mute: #6b6b68;
--faint: #b9b8b2;
--rule: #e3e1db;
--rule-2: #ccc9c1;
--accent: #1f4e5f;
--accent-tint: #e9eff1;
--alarm: #8a3a2a;
--warm: #a77a2c;
--good: #3a6f3f;
--serif: Georgia, "Iowan Old Style", "Palatino Linotype", serif;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue",
"Inter", "Arial", sans-serif;
--mono: "JetBrains Mono", "SF Mono", "Menlo", "Consolas", ui-monospace,
monospace;
--fs-base: 14px;
--lh-base: 1.55;
--space: 1rem;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: var(--page);
color: var(--ink);
font-family: var(--sans);
font-size: var(--fs-base);
line-height: var(--lh-base);
font-feature-settings: "tnum" 1, "cv11" 1; /* tabular numerals */
-webkit-font-smoothing: antialiased;
}
code, kbd, pre, samp, .mono, .num {
font-family: var(--mono);
font-feature-settings: "tnum" 1;
}
a {
color: var(--accent);
text-decoration: none;
border-bottom: 1px solid transparent;
transition: border-color 120ms ease;
}
a:hover { border-bottom-color: var(--accent); }
hr {
border: 0;
border-top: 1px solid var(--rule);
margin: 2rem 0;
}
/* ---------- shell ------------------------------------------------------- */
.masthead {
border-bottom: 1px solid var(--rule);
padding: 1.6rem 2.2rem 1.2rem;
background: var(--page);
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 2rem;
}
.masthead .title {
font-family: var(--serif);
font-size: 1.55rem;
letter-spacing: -0.01em;
font-weight: 500;
margin: 0;
}
.masthead .title em {
font-style: italic;
color: var(--mute);
font-weight: 400;
}
.masthead .meta {
text-align: right;
color: var(--mute);
font-size: 0.78rem;
line-height: 1.5;
font-family: var(--mono);
}
.masthead .meta .dot {
display: inline-block; width: 6px; height: 6px; border-radius: 50%;
background: var(--good); margin-right: 4px; vertical-align: 1px;
}
.masthead .meta .dot.bad { background: var(--alarm); }
main {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
gap: 0;
max-width: 1440px;
margin: 0 auto;
min-height: calc(100vh - 72px);
}
.col-left {
padding: 1.8rem 2rem 2rem 2.2rem;
border-right: 1px solid var(--rule);
}
.col-right {
padding: 1.8rem 2.2rem 2rem 2rem;
}
@media (max-width: 940px) {
main { grid-template-columns: 1fr; }
.col-left { border-right: 0; border-bottom: 1px solid var(--rule); }
}
/* ---------- typography / section labels -------------------------------- */
.section-label {
font-family: var(--sans);
font-size: 0.70rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--mute);
margin: 0 0 0.6rem;
padding-bottom: 0.35rem;
border-bottom: 1px solid var(--rule);
display: flex;
align-items: baseline;
justify-content: space-between;
}
.section-label .ordinal {
font-family: var(--mono);
font-weight: 500;
color: var(--faint);
font-size: 0.72rem;
letter-spacing: 0;
}
.section + .section { margin-top: 1.9rem; }
.lead {
color: var(--mute);
font-size: 0.87rem;
font-style: italic;
font-family: var(--serif);
margin: 0.4rem 0 0.9rem;
max-width: 44ch;
}
/* ---------- form ------------------------------------------------------- */
.form-grid {
display: grid;
grid-template-columns: max-content 1fr;
row-gap: 0.55rem;
column-gap: 1.2rem;
align-items: baseline;
}
.form-grid label {
font-size: 0.82rem;
color: var(--ink);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.form-grid label .hint {
display: block;
font-size: 0.72rem;
color: var(--mute);
font-style: italic;
font-family: var(--serif);
margin-top: 1px;
white-space: normal;
max-width: 24ch;
}
.form-grid input[type="text"],
.form-grid input[type="number"],
.form-grid select {
font-family: var(--mono);
font-size: 0.85rem;
color: var(--ink);
background: transparent;
border: 0;
border-bottom: 1px solid var(--rule-2);
padding: 3px 2px 4px;
width: 100%;
outline: none;
transition: border-color 120ms ease, background 120ms ease;
border-radius: 0;
}
.form-grid input[type="text"]:focus,
.form-grid input[type="number"]:focus,
.form-grid select:focus {
border-bottom-color: var(--accent);
background: var(--accent-tint);
}
.form-grid input[readonly] {
color: var(--mute);
background: transparent;
border-bottom-style: dotted;
}
.form-grid input[type="checkbox"] {
accent-color: var(--accent);
margin: 0;
transform: translateY(1px);
}
select {
appearance: none;
-webkit-appearance: none;
background-image: linear-gradient(45deg, transparent 50%, var(--mute) 50%),
linear-gradient(-45deg, transparent 50%, var(--mute) 50%);
background-position: calc(100% - 10px) 50%, calc(100% - 5px) 50%;
background-size: 5px 5px;
background-repeat: no-repeat;
padding-right: 18px;
}
/* reducer picker — a typographic list, not a dropdown */
.reducer-list {
list-style: none;
padding: 0;
margin: 0 0 0.4rem;
display: flex;
flex-direction: column;
border-top: 1px solid var(--rule);
}
.reducer-list li { border-bottom: 1px solid var(--rule); }
.reducer-list input[type="radio"] { display: none; }
.reducer-list label {
display: grid;
grid-template-columns: 1.4rem 1fr auto;
gap: 0.6rem;
padding: 0.55rem 0.2rem 0.55rem 0.1rem;
cursor: pointer;
transition: background 100ms ease;
}
.reducer-list label:hover { background: var(--accent-tint); }
.reducer-list .mark {
font-family: var(--mono);
color: var(--faint);
text-align: center;
font-size: 0.9rem;
line-height: 1.1;
}
.reducer-list input:checked + label {
background: var(--accent-tint);
}
.reducer-list input:checked + label .mark { color: var(--accent); }
.reducer-list input:checked + label .name { color: var(--accent); font-weight: 600; }
.reducer-list .name {
font-family: var(--sans);
font-size: 0.88rem;
font-weight: 500;
}
.reducer-list .blurb {
display: block;
color: var(--mute);
font-size: 0.76rem;
font-style: italic;
font-family: var(--serif);
margin-top: 1px;
}
.reducer-list .pkg {
font-family: var(--mono);
color: var(--faint);
font-size: 0.72rem;
align-self: start;
}
/* advanced disclosure */
details.advanced {
margin-top: 0.8rem;
border-top: 1px dashed var(--rule);
padding-top: 0.6rem;
}
details.advanced > summary {
cursor: pointer;
list-style: none;
color: var(--mute);
font-size: 0.74rem;
text-transform: uppercase;
letter-spacing: 0.12em;
font-weight: 600;
padding: 0.2rem 0;
user-select: none;
}
details.advanced > summary::-webkit-details-marker { display: none; }
details.advanced > summary::before {
content: "[ + ]";
font-family: var(--mono);
color: var(--faint);
margin-right: 0.5rem;
letter-spacing: 0;
}
details.advanced[open] > summary::before { content: "[ ]"; color: var(--accent); }
.advanced-body { padding-top: 0.5rem; }
/* submit */
.actions {
margin-top: 1.4rem;
padding-top: 1rem;
border-top: 1px solid var(--rule);
display: flex;
align-items: center;
gap: 1rem;
}
button.submit {
font-family: var(--sans);
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--page);
background: var(--accent);
border: 1px solid var(--accent);
padding: 0.55rem 1.2rem;
border-radius: 1px;
cursor: pointer;
transition: background 120ms ease;
}
button.submit:hover { background: #143642; }
button.submit:disabled { background: var(--faint); border-color: var(--faint); cursor: not-allowed; }
.actions .foot-note {
font-size: 0.72rem;
color: var(--mute);
font-style: italic;
font-family: var(--serif);
}
.flash {
padding: 0.6rem 0.8rem;
margin: 0.6rem 0;
border-left: 2px solid var(--accent);
background: var(--accent-tint);
font-size: 0.82rem;
}
.flash.err { border-left-color: var(--alarm); background: #f4e8e4; color: var(--alarm); }
/* ---------- runs list -------------------------------------------------- */
.run-count {
font-family: var(--mono);
color: var(--faint);
font-size: 0.72rem;
letter-spacing: 0;
text-transform: none;
}
.runs {
margin: 0;
padding: 0;
list-style: none;
border-top: 1px solid var(--rule);
}
.runs li.run {
border-bottom: 1px solid var(--rule);
padding: 0.85rem 0 0.85rem;
display: grid;
grid-template-columns: 5.5rem 1fr;
column-gap: 1rem;
align-items: start;
}
.runs li.run.just-submitted {
background: linear-gradient(to right, var(--accent-tint), transparent 60%);
padding-left: 0.55rem;
margin-left: -0.55rem;
}
.run .stamp {
font-family: var(--mono);
font-size: 0.72rem;
color: var(--mute);
line-height: 1.3;
padding-top: 2px;
}
.run .stamp .id {
display: block;
color: var(--faint);
font-size: 0.68rem;
margin-top: 3px;
}
.run .body { min-width: 0; }
.run .line1 {
display: flex;
gap: 0.55rem;
align-items: baseline;
flex-wrap: wrap;
}
.run .badge {
font-family: var(--mono);
font-size: 0.68rem;
letter-spacing: 0.05em;
text-transform: uppercase;
padding: 1px 6px;
border: 1px solid currentColor;
border-radius: 1px;
line-height: 1.2;
}
.badge.running { color: var(--accent); background: var(--accent-tint); }
.badge.scheduled,
.badge.pending { color: var(--mute); }
.badge.completed { color: var(--good); background: #edf1ed; }
.badge.failed,
.badge.crashed,
.badge.cancelled { color: var(--alarm); background: #f4e8e4; }
.run .recipe {
font-family: var(--serif);
font-size: 0.92rem;
font-style: italic;
color: var(--ink);
}
.run .recipe .embedder { font-style: normal; font-family: var(--mono); font-size: 0.82rem; color: var(--accent); }
.run .recipe .generator { font-style: normal; font-family: var(--mono); font-size: 0.82rem; color: var(--mute); }
.run .paramline {
margin-top: 0.25rem;
font-family: var(--mono);
font-size: 0.72rem;
color: var(--mute);
display: flex;
flex-wrap: wrap;
gap: 0 1.1rem;
row-gap: 2px;
}
.run .paramline .k { color: var(--faint); }
.run .outputs {
margin-top: 0.45rem;
display: flex;
flex-wrap: wrap;
gap: 0 1.2rem;
row-gap: 4px;
font-size: 0.8rem;
font-family: var(--sans);
}
.run .outputs .tag {
font-family: var(--mono);
font-size: 0.7rem;
color: var(--faint);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-right: 0.25rem;
}
.run .outputs a[aria-disabled="true"] {
color: var(--faint);
font-style: italic;
cursor: default;
pointer-events: none;
}
.run .outputs a[aria-disabled="true"]::after {
content: " — waiting";
color: var(--faint);
font-size: 0.72rem;
font-family: var(--serif);
font-style: italic;
}
/* empty runs */
.empty {
padding: 1.4rem 0;
color: var(--mute);
font-style: italic;
font-family: var(--serif);
border-bottom: 1px solid var(--rule);
}
/* footer */
.colophon {
max-width: 1440px;
margin: 0 auto;
padding: 1rem 2.2rem 2rem;
color: var(--faint);
font-size: 0.72rem;
font-family: var(--mono);
letter-spacing: 0;
display: flex;
justify-content: space-between;
gap: 1rem;
border-top: 1px solid var(--rule);
}
.colophon span.k { color: var(--mute); }
/* subtle htmx indicator */
.htmx-indicator {
opacity: 0;
transition: opacity 180ms ease;
color: var(--mute);
font-family: var(--mono);
font-size: 0.74rem;
}
.htmx-request .htmx-indicator { opacity: 1; }
.htmx-request.htmx-indicator { opacity: 1; }

View File

@ -0,0 +1,89 @@
{#
Partial: the parameter rows for one reducer.
Expects: reducer_key (str) OR default_reducer (on first render)
spec (dict) OR default_spec
#}
{% set rk = reducer_key if reducer_key is defined else default_reducer %}
{% set s = spec if spec is defined else default_spec %}
{% if not s %}
<p class="flash err">No reducers available — no supported package importable.</p>
{% else %}
<div class="form-grid">
{% for name, kind, default, choices, help in s.key %}
{% set locked = (name in ('n_components', 'n_dims')) %}
<label for="embed__{{ name }}">
{{ name }}
{% if help %}<span class="hint">{{ help }}</span>{% endif %}
</label>
{% if kind == 'bool' %}
<span>
<input type="checkbox" id="embed__{{ name }}" name="embed__{{ name }}"
{% if default %}checked{% endif %} />
</span>
{% elif choices %}
<select id="embed__{{ name }}" name="embed__{{ name }}">
{% for c in choices %}
<option value="{{ c }}" {% if c == default %}selected{% endif %}>{{ c if c != "" else "(none)" }}</option>
{% endfor %}
</select>
{% elif kind == 'int' %}
<input type="number" step="1" id="embed__{{ name }}" name="embed__{{ name }}"
value="{{ default }}" {% if locked %}readonly{% endif %} />
{% elif kind == 'float' %}
<input type="number" step="any" id="embed__{{ name }}" name="embed__{{ name }}"
value="{{ default }}" />
{% elif kind == 'int_or_null' %}
<input type="text" id="embed__{{ name }}" name="embed__{{ name }}"
value="{{ default }}" placeholder="int or empty" />
{% else %}
<input type="text" id="embed__{{ name }}" name="embed__{{ name }}"
value="{{ default }}" />
{% endif %}
{% endfor %}
</div>
{% if s.advanced %}
<details class="advanced">
<summary>show advanced</summary>
<div class="advanced-body">
<div class="form-grid">
{% for name, kind, default, choices, help in s.advanced %}
<label for="embed__{{ name }}">
{{ name }}
{% if help %}<span class="hint">{{ help }}</span>{% endif %}
</label>
{% if kind == 'bool' %}
<span>
<input type="checkbox" id="embed__{{ name }}" name="embed__{{ name }}"
{% if default %}checked{% endif %} />
</span>
{% elif choices %}
<select id="embed__{{ name }}" name="embed__{{ name }}">
{% for c in choices %}
<option value="{{ c }}" {% if c == default %}selected{% endif %}>{{ c if c != "" else "(none)" }}</option>
{% endfor %}
</select>
{% elif kind == 'int' %}
<input type="number" step="1" id="embed__{{ name }}" name="embed__{{ name }}"
value="{{ default }}" />
{% elif kind == 'float' %}
<input type="number" step="any" id="embed__{{ name }}" name="embed__{{ name }}"
value="{{ default }}" />
{% elif kind == 'int_or_null' %}
<input type="text" id="embed__{{ name }}" name="embed__{{ name }}"
value="{{ default }}" placeholder="int or empty" />
{% else %}
<input type="text" id="embed__{{ name }}" name="embed__{{ name }}"
value="{{ default }}" />
{% endif %}
{% endfor %}
</div>
</div>
</details>
{% endif %}
{% endif %}

View File

@ -0,0 +1,68 @@
{#
Partial: runs list (right column).
Expects: runs (list of normalised view dicts), optional just_submitted (id)
#}
{% if not runs %}
<div class="empty">No runs yet. Dispatch one from the form on the left.</div>
{% else %}
<ul class="runs">
{% for r in runs %}
<li class="run {% if just_submitted is defined and r.id == just_submitted %}just-submitted{% endif %}">
<div class="stamp">
{% if r.start %}{{ r.start[:10] }}<br/>{{ r.start[11:19] }}{% else %}&nbsp;{% endif %}
<span class="id">#{{ r.short_id }}</span>
</div>
<div class="body">
<div class="line1">
<span class="badge {{ r.state_type|lower }}">{{ r.state_name }}</span>
<span class="recipe">
<span class="embedder">{{ r.embedder_short or "?" }}</span>
<em>on</em>
<span class="generator">{{ r.generator_short or "?" }}</span>
</span>
</div>
{% if r.params %}
<div class="paramline">
<span><span class="k">N</span>&nbsp;{{ r.params.get('num_points', '?') }}</span>
<span><span class="k">S</span>&nbsp;{{ r.params.get('num_snapshots', '?') }}</span>
<span><span class="k">J</span>&nbsp;{{ r.params.get('jitter_scale', '?') }}</span>
<span><span class="k">s</span>&nbsp;{{ r.params.get('seed', '?') }}</span>
{% set ea = r.params.get('embed_args') or {} %}
{% if ea %}
{% for k, v in ea.items() %}
{% if k not in ('n_components','n_dims','random_state') %}
<span><span class="k">{{ k }}</span>&nbsp;{{ v }}</span>
{% endif %}
{% endfor %}
{% endif %}
</div>
{% endif %}
<div class="outputs">
<span class="tag">fig</span>
{% if r.ref_file %}
{% if r.ref_exists %}
<a href="/figs/{{ r.ref_file }}" target="_blank" rel="noopener">reference</a>
{% else %}
<a aria-disabled="true">reference</a>
{% endif %}
{% else %}
<span style="color:var(--faint);font-style:italic">reference: n/a</span>
{% endif %}
{% if r.emb_file %}
{% if r.emb_exists %}
<a href="/figs/{{ r.emb_file }}" target="_blank" rel="noopener">embedding</a>
{% else %}
<a aria-disabled="true">embedding</a>
{% endif %}
{% else %}
<span style="color:var(--faint);font-style:italic">embedding: n/a</span>
{% endif %}
</div>
</div>
</li>
{% endfor %}
</ul>
{% endif %}

View File

@ -0,0 +1,153 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>embedding notebook — web1</title>
<link rel="stylesheet" href="/static/style.css" />
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3' fill='%231f4e5f'/%3E%3C/svg%3E" />
</head>
<body>
<header class="masthead">
<div>
<h1 class="title">embedding notebook <em>&mdash; drift &amp; projection</em></h1>
</div>
<div class="meta">
<span class="dot {% if not deployment_id %}bad{% endif %}"></span>
{% if deployment_id %}prefect · deployment {{ deployment_id[:8] }}{% else %}prefect · unreachable{% endif %}<br/>
<span style="color:var(--faint)">{{ prefect_api }}</span>
</div>
</header>
<main>
<!-- ==================== LEFT: parameter notebook ==================== -->
<section class="col-left">
<form
id="run-form"
hx-post="/submit"
hx-target="#runs-slot"
hx-swap="innerHTML"
hx-indicator="#busy"
>
<!-- §1 reducer -->
<div class="section">
<div class="section-label">
<span>§ 1 &nbsp; reducer</span><span class="ordinal">method</span>
</div>
<p class="lead">Dimensionality reduction applied to each snapshot. Only reducers whose Python package is importable are shown.</p>
<ul class="reducer-list">
{% for r in reducers %}
<li>
<input type="radio" name="reducer" id="red-{{ loop.index }}" value="{{ r.key }}"
{% if r.key == default_reducer %}checked{% endif %}
hx-get="/reducer-form?name={{ r.key }}"
hx-target="#reducer-params"
hx-swap="innerHTML"
hx-trigger="change" />
<label for="red-{{ loop.index }}">
<span class="mark">{{ "%02d"|format(loop.index) }}</span>
<span>
<span class="name">{{ r.label }}</span>
<span class="blurb">{{ r.blurb }}</span>
</span>
<span class="pkg">{{ r.key }}</span>
</label>
</li>
{% endfor %}
</ul>
</div>
<!-- §2 reducer params -->
<div class="section">
<div class="section-label">
<span>§ 2 &nbsp; parameters</span><span class="ordinal">kwargs</span>
</div>
<div id="reducer-params">
{% include "_reducer_form.html" with context %}
</div>
</div>
<!-- §3 data -->
<div class="section">
<div class="section-label">
<span>§ 3 &nbsp; data &amp; drift</span><span class="ordinal">sampling</span>
</div>
<div class="form-grid">
<label for="generator_path">
generator
<span class="hint">data-generating surface</span>
</label>
<select name="generator_path" id="generator_path">
{% for path, short in generators %}
<option value="{{ path }}" {% if path == 'sklearn.datasets.make_s_curve' %}selected{% endif %}>{{ short }}</option>
{% endfor %}
</select>
<label for="num_points">n<sub>points</sub></label>
<input type="number" id="num_points" name="num_points" value="5000" min="100" step="100" />
<label for="num_snapshots">n<sub>snapshots</sub></label>
<input type="number" id="num_snapshots" name="num_snapshots" value="48" min="2" step="1" />
<label for="jitter_scale">
jitter scale
<span class="hint">std of per-step Gaussian drift</span>
</label>
<input type="number" id="jitter_scale" name="jitter_scale" value="0.01" step="0.001" min="0" />
<label for="seed">
jitter seed
<span class="hint">seeds only the drift simulation — the embedder's seed is in §2 (advanced).</span>
</label>
<input type="number" id="seed" name="seed" value="42" step="1" />
</div>
</div>
<div class="actions">
<button type="submit" class="submit">submit run</button>
<span id="busy" class="htmx-indicator">dispatching&hellip;</span>
</div>
</form>
</section>
<!-- ==================== RIGHT: runs log ============================== -->
<section class="col-right">
<div class="section-label">
<span>§ 4 &nbsp; recent runs</span>
<span class="run-count">
<span id="runs-count">{{ runs|length }}</span> / 10 · refresh 3s
<span id="poll-ind" class="htmx-indicator" style="margin-left:6px">&#9679;</span>
</span>
</div>
<div
id="runs-slot"
hx-get="/runs"
hx-trigger="load delay:3s, every 3s"
hx-swap="innerHTML"
hx-indicator="#poll-ind"
>
{% include "_runs.html" with context %}
</div>
</section>
</main>
<footer class="colophon">
<span><span class="k">web1</span> · scientific instrument · port 8001</span>
<span>fastapi · htmx · no build step</span>
</footer>
</body>
</html>