web1 checkpoint
This commit is contained in:
parent
261da42c00
commit
22ca411210
0
app/web1/__init__.py
Normal file
0
app/web1/__init__.py
Normal file
507
app/web1/main.py
Normal file
507
app/web1/main.py
Normal 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 ~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("<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
515
app/web1/static/style.css
Normal 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; }
|
||||
89
app/web1/templates/_reducer_form.html
Normal file
89
app/web1/templates/_reducer_form.html
Normal 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 %}
|
||||
68
app/web1/templates/_runs.html
Normal file
68
app/web1/templates/_runs.html
Normal 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 %} {% 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> {{ r.params.get('num_points', '?') }}</span>
|
||||
<span><span class="k">S</span> {{ r.params.get('num_snapshots', '?') }}</span>
|
||||
<span><span class="k">J</span> {{ r.params.get('jitter_scale', '?') }}</span>
|
||||
<span><span class="k">s</span> {{ 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> {{ 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 %}
|
||||
153
app/web1/templates/index.html
Normal file
153
app/web1/templates/index.html
Normal 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>— drift & 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 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 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 data & 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…</span>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- ==================== RIGHT: runs log ============================== -->
|
||||
<section class="col-right">
|
||||
|
||||
<div class="section-label">
|
||||
<span>§ 4 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">●</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>
|
||||
Loading…
Reference in New Issue
Block a user