From 22ca41121010faa1e0054d2cf9ed0f98fb38ad8c Mon Sep 17 00:00:00 2001 From: Michael Pilosov Date: Tue, 21 Apr 2026 19:04:23 -0600 Subject: [PATCH] web1 checkpoint --- app/web1/__init__.py | 0 app/web1/main.py | 507 +++++++++++++++++++++++++ app/web1/static/style.css | 515 ++++++++++++++++++++++++++ app/web1/templates/_reducer_form.html | 89 +++++ app/web1/templates/_runs.html | 68 ++++ app/web1/templates/index.html | 153 ++++++++ 6 files changed, 1332 insertions(+) create mode 100644 app/web1/__init__.py create mode 100644 app/web1/main.py create mode 100644 app/web1/static/style.css create mode 100644 app/web1/templates/_reducer_form.html create mode 100644 app/web1/templates/_runs.html create mode 100644 app/web1/templates/index.html diff --git a/app/web1/__init__.py b/app/web1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/web1/main.py b/app/web1/main.py new file mode 100644 index 0000000..006143b --- /dev/null +++ b/app/web1/main.py @@ -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 ~162–168) +# --------------------------------------------------------------------------- + + +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("

unknown reducer

", 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"
unknown reducer: {reducer}
", + 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"
bad numeric input: {e}
", 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( + "
could not reach Prefect API at " + f"{PREFECT_API}
", + status_code=502, + ) + if "error" in run: + return HTMLResponse( + f"
prefect error ({run.get('status')}): " + f"{run.get('error')[:500]}
", + 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} + ) diff --git a/app/web1/static/style.css b/app/web1/static/style.css new file mode 100644 index 0000000..706443e --- /dev/null +++ b/app/web1/static/style.css @@ -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; } diff --git a/app/web1/templates/_reducer_form.html b/app/web1/templates/_reducer_form.html new file mode 100644 index 0000000..1f8510e --- /dev/null +++ b/app/web1/templates/_reducer_form.html @@ -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 %} +

No reducers available — no supported package importable.

+{% else %} + +
+ {% for name, kind, default, choices, help in s.key %} + {% set locked = (name in ('n_components', 'n_dims')) %} + + + {% if kind == 'bool' %} + + + + {% elif choices %} + + {% elif kind == 'int' %} + + {% elif kind == 'float' %} + + {% elif kind == 'int_or_null' %} + + {% else %} + + {% endif %} + {% endfor %} +
+ + {% if s.advanced %} +
+ show advanced +
+
+ {% for name, kind, default, choices, help in s.advanced %} + + + {% if kind == 'bool' %} + + + + {% elif choices %} + + {% elif kind == 'int' %} + + {% elif kind == 'float' %} + + {% elif kind == 'int_or_null' %} + + {% else %} + + {% endif %} + {% endfor %} +
+
+
+ {% endif %} + +{% endif %} diff --git a/app/web1/templates/_runs.html b/app/web1/templates/_runs.html new file mode 100644 index 0000000..673783f --- /dev/null +++ b/app/web1/templates/_runs.html @@ -0,0 +1,68 @@ +{# + Partial: runs list (right column). + Expects: runs (list of normalised view dicts), optional just_submitted (id) +#} +{% if not runs %} +
No runs yet. Dispatch one from the form on the left.
+{% else %} + +{% endif %} diff --git a/app/web1/templates/index.html b/app/web1/templates/index.html new file mode 100644 index 0000000..65134f7 --- /dev/null +++ b/app/web1/templates/index.html @@ -0,0 +1,153 @@ + + + + + + embedding notebook — web1 + + + + + + +
+
+

embedding notebook — drift & projection

+
+
+ + {% if deployment_id %}prefect · deployment {{ deployment_id[:8] }}{% else %}prefect · unreachable{% endif %}
+ {{ prefect_api }} +
+
+ +
+ + +
+ +
+ + +
+ +

Dimensionality reduction applied to each snapshot. Only reducers whose Python package is importable are shown.

+ +
    + {% for r in reducers %} +
  • + + +
  • + {% endfor %} +
+
+ + +
+ +
+ {% include "_reducer_form.html" with context %} +
+
+ + +
+ + +
+ + + + + + + + + + + + + + +
+
+ +
+ + dispatching… +
+ +
+ +
+ + +
+ + + +
+ {% include "_runs.html" with context %} +
+ +
+ +
+ + + + +