Compare commits
9 Commits
c12d2cda6c
...
ba7eef9df0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba7eef9df0 | ||
|
|
59a6bece2e | ||
|
|
e94d28b8fc | ||
|
|
56279dbb1b | ||
|
|
aa1303e373 | ||
|
|
4ecdc7f586 | ||
|
|
d70eff3704 | ||
|
|
4576088c73 | ||
|
|
d052ec4223 |
177
app/web/main.py
177
app/web/main.py
@ -475,6 +475,18 @@ def run_args_hash(
|
|||||||
embed_args_hash = run_args_hash
|
embed_args_hash = run_args_hash
|
||||||
|
|
||||||
|
|
||||||
|
def sci_notation(v: Any) -> str:
|
||||||
|
"""Float → compact sci notation without a period (0.005 → '5E-3').
|
||||||
|
Used in stems and Prefect run names so filenames + UI avoid periods."""
|
||||||
|
try:
|
||||||
|
f = float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return str(v)
|
||||||
|
m, e = f"{f:.3e}".split("e")
|
||||||
|
m = m.rstrip("0").rstrip(".")
|
||||||
|
return f"{m}E{int(e)}"
|
||||||
|
|
||||||
|
|
||||||
def synthesize_output_paths(
|
def synthesize_output_paths(
|
||||||
generator_path: str,
|
generator_path: str,
|
||||||
embedder: str,
|
embedder: str,
|
||||||
@ -487,8 +499,9 @@ def synthesize_output_paths(
|
|||||||
) -> Tuple[str, str]:
|
) -> Tuple[str, str]:
|
||||||
gen = generator_path.split(".")[-1]
|
gen = generator_path.split(".")[-1]
|
||||||
emb = embedder.split(".")[-1]
|
emb = embedder.split(".")[-1]
|
||||||
ref = f"{gen}_Reference_N{num_points}_T{num_timesteps}_J{jitter_scale}_s{seed}.html"
|
j = sci_notation(jitter_scale)
|
||||||
base = f"{gen}_{emb}_N{num_points}_T{num_timesteps}_J{jitter_scale}_s{seed}"
|
ref = f"{gen}_Reference_N{num_points}_T{num_timesteps}_J{j}_s{seed}.html"
|
||||||
|
base = f"{gen}_{emb}_N{num_points}_T{num_timesteps}_J{j}_s{seed}"
|
||||||
if embed_args is None:
|
if embed_args is None:
|
||||||
embf = f"{base}.html"
|
embf = f"{base}.html"
|
||||||
else:
|
else:
|
||||||
@ -534,32 +547,46 @@ class Prefect:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async def create_run(
|
async def create_run(
|
||||||
self, client: httpx.AsyncClient, parameters: Dict[str, Any]
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
parameters: Dict[str, Any],
|
||||||
|
tags: Optional[List[str]] = None,
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
dep = await self.deployment_id(client)
|
dep = await self.deployment_id(client)
|
||||||
if not dep:
|
if not dep:
|
||||||
return None
|
return None
|
||||||
|
body: Dict[str, Any] = {"parameters": parameters}
|
||||||
|
if tags:
|
||||||
|
body["tags"] = list(tags)
|
||||||
r = await client.post(
|
r = await client.post(
|
||||||
f"{self.base}/deployments/{dep}/create_flow_run",
|
f"{self.base}/deployments/{dep}/create_flow_run",
|
||||||
json={"parameters": parameters},
|
json=body,
|
||||||
)
|
)
|
||||||
if r.status_code >= 400:
|
if r.status_code >= 400:
|
||||||
return {"error": r.text, "status": r.status_code}
|
return {"error": r.text, "status": r.status_code}
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
async def recent_runs(
|
async def recent_runs(
|
||||||
self, client: httpx.AsyncClient, limit: int = 10
|
self,
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
limit: int = 10,
|
||||||
|
required_tags: Optional[List[str]] = None,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
dep = await self.deployment_id(client)
|
dep = await self.deployment_id(client)
|
||||||
if not dep:
|
if not dep:
|
||||||
return []
|
return []
|
||||||
|
flow_runs: Dict[str, Any] = {"deployment_id": {"any_": [dep]}}
|
||||||
|
if required_tags:
|
||||||
|
flow_runs["tags"] = {"all_": list(required_tags)}
|
||||||
|
# Prefect rejects limit > 200 with HTTP 422.
|
||||||
|
capped = min(max(1, limit), 200)
|
||||||
try:
|
try:
|
||||||
r = await client.post(
|
r = await client.post(
|
||||||
f"{self.base}/flow_runs/filter",
|
f"{self.base}/flow_runs/filter",
|
||||||
json={
|
json={
|
||||||
"sort": "START_TIME_DESC",
|
"sort": "START_TIME_DESC",
|
||||||
"limit": limit,
|
"limit": capped,
|
||||||
"flow_runs": {"deployment_id": {"any_": [dep]}},
|
"flow_runs": flow_runs,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
@ -568,6 +595,19 @@ class Prefect:
|
|||||||
return []
|
return []
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
async def update_tags(
|
||||||
|
self, client: httpx.AsyncClient, run_id: str, tags: List[str]
|
||||||
|
) -> bool:
|
||||||
|
try:
|
||||||
|
r = await client.patch(
|
||||||
|
f"{self.base}/flow_runs/{run_id}",
|
||||||
|
json={"tags": list(tags)},
|
||||||
|
)
|
||||||
|
return r.status_code < 400
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return False
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
PREFECT = Prefect()
|
PREFECT = Prefect()
|
||||||
|
|
||||||
@ -663,7 +703,9 @@ def _run_view(run: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
"ref_exists": ref_exists,
|
"ref_exists": ref_exists,
|
||||||
"emb_exists": emb_exists,
|
"emb_exists": emb_exists,
|
||||||
"embedder_short": (params.get("embedder") or "").split(".")[-1],
|
"embedder_short": (params.get("embedder") or "").split(".")[-1],
|
||||||
"generator_short": (params.get("generator_path") or "").split(".")[-1],
|
"generator_short": _dataset_id(
|
||||||
|
params.get("generator_path") or "", params.get("generator_kwargs") or {}
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -736,10 +778,49 @@ async def reducer_form(request: Request, name: str) -> HTMLResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _chip_filter_tags(params) -> List[str]:
|
||||||
|
"""Turn chip-filter query params (?dataset=…&algorithm=…&N=…&T=…&J=…)
|
||||||
|
into a Prefect `tags all_` list. Empty / missing values skip the axis."""
|
||||||
|
keys = ("dataset", "algorithm", "N", "T", "J")
|
||||||
|
tags = []
|
||||||
|
for k in keys:
|
||||||
|
v = (params.get(k) or "").strip()
|
||||||
|
if v:
|
||||||
|
tags.append(f"{k}:{v}")
|
||||||
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/runs/axes.json")
|
||||||
|
async def runs_axes() -> JSONResponse:
|
||||||
|
"""Distinct chip values across the last N deployment-scoped runs. Lets
|
||||||
|
the chip bar show the full universe regardless of the current filter."""
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
runs = await PREFECT.recent_runs(client, limit=500)
|
||||||
|
values: Dict[str, set] = {k: set() for k in ("dataset", "algorithm", "N", "T", "J")}
|
||||||
|
for r in runs:
|
||||||
|
for tag in r.get("tags") or []:
|
||||||
|
if ":" not in tag:
|
||||||
|
continue
|
||||||
|
k, _, v = tag.partition(":")
|
||||||
|
if k in values:
|
||||||
|
values[k].add(v)
|
||||||
|
# Sort numeric axes numerically.
|
||||||
|
def _sort(k, vs):
|
||||||
|
if k in ("N", "T", "J"):
|
||||||
|
return sorted(vs, key=lambda x: float(x) if x else 0.0)
|
||||||
|
return sorted(vs)
|
||||||
|
return JSONResponse({k: _sort(k, v) for k, v in values.items()})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/runs", response_class=HTMLResponse)
|
@app.get("/runs", response_class=HTMLResponse)
|
||||||
async def runs_partial(request: Request) -> HTMLResponse:
|
async def runs_partial(request: Request) -> HTMLResponse:
|
||||||
|
required = _chip_filter_tags(request.query_params)
|
||||||
|
# Server-side tag filter → one narrow query per chip state. When any
|
||||||
|
# axis is unfiltered, Prefect returns the K most recent for that slice;
|
||||||
|
# when fully filtered, usually a handful of exact matches.
|
||||||
|
limit = 50 if required else 10
|
||||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
runs = await PREFECT.recent_runs(client, limit=10)
|
runs = await PREFECT.recent_runs(client, limit=limit, required_tags=required)
|
||||||
views = [_run_view(r) for r in runs]
|
views = [_run_view(r) for r in runs]
|
||||||
_mark_stale_views(views)
|
_mark_stale_views(views)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
@ -839,8 +920,13 @@ async def submit(request: Request) -> HTMLResponse:
|
|||||||
if generator_kwargs:
|
if generator_kwargs:
|
||||||
parameters["generator_kwargs"] = generator_kwargs
|
parameters["generator_kwargs"] = generator_kwargs
|
||||||
|
|
||||||
|
tags = build_run_tags(
|
||||||
|
generator_path, generator_kwargs, reducer,
|
||||||
|
num_points, num_timesteps, jitter_scale,
|
||||||
|
)
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
run = await PREFECT.create_run(client, parameters)
|
run = await PREFECT.create_run(client, parameters, tags=tags)
|
||||||
|
|
||||||
if not run:
|
if not run:
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
@ -904,7 +990,7 @@ async def metrics_json() -> JSONResponse:
|
|||||||
|
|
||||||
|
|
||||||
_STEM_RE = re.compile(
|
_STEM_RE = re.compile(
|
||||||
r"^make_[A-Za-z_]+?_[A-Za-z]+_N\d+_T\d+_J[\d.]+_s\d+(?:_[0-9a-f]{8})?$"
|
r"^make_[A-Za-z_]+?_[A-Za-z]+_N\d+_T\d+_J[\d.Ee+\-]+_s\d+(?:_[0-9a-f]{8})?$"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Map short generator name ("make_blobs") to its DATASET_META entry.
|
# Map short generator name ("make_blobs") to its DATASET_META entry.
|
||||||
@ -914,6 +1000,62 @@ for _m in DATASET_META.values():
|
|||||||
_GEN_TO_META.setdefault(_m["path"].rsplit(".", 1)[-1], _m)
|
_GEN_TO_META.setdefault(_m["path"].rsplit(".", 1)[-1], _m)
|
||||||
|
|
||||||
|
|
||||||
|
# Kwargs the flow injects / we supply explicitly — never part of the
|
||||||
|
# dataset's semantic identity, so strip them before DATASET_META matching
|
||||||
|
# and before regenerating labels.
|
||||||
|
_TRANSIENT_GEN_KWARGS = {"n_samples", "random_state"}
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_gen_kwargs(gk: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||||
|
if gk is None:
|
||||||
|
return None
|
||||||
|
return {k: v for k, v in gk.items() if k not in _TRANSIENT_GEN_KWARGS}
|
||||||
|
|
||||||
|
|
||||||
|
# Tag axes the chip-filter and backfill both care about. Keep as
|
||||||
|
# (short_prefix, builder) pairs so adding an axis is a one-line change.
|
||||||
|
TAG_AXES = ("dataset", "algorithm", "N", "T", "J")
|
||||||
|
|
||||||
|
|
||||||
|
def build_run_tags(
|
||||||
|
generator_path: str,
|
||||||
|
generator_kwargs: Optional[Dict[str, Any]],
|
||||||
|
embedder: str,
|
||||||
|
num_points: int,
|
||||||
|
num_timesteps: int,
|
||||||
|
jitter_scale: float,
|
||||||
|
) -> List[str]:
|
||||||
|
"""Tags written onto every flow run so the chip filter can narrow
|
||||||
|
server-side via Prefect's tag:all_ filter. Single value per axis; the
|
||||||
|
client's cassette chips pick exactly one per filter."""
|
||||||
|
return [
|
||||||
|
f"dataset:{_dataset_id(generator_path, generator_kwargs)}",
|
||||||
|
f"algorithm:{(embedder or '').rsplit('.', 1)[-1]}",
|
||||||
|
f"N:{int(num_points)}",
|
||||||
|
f"T:{int(num_timesteps)}",
|
||||||
|
f"J:{sci_notation(jitter_scale)}",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _dataset_id(generator_path: str, generator_kwargs: Optional[Dict[str, Any]]) -> str:
|
||||||
|
"""Human-scale identifier for a run's dataset — e.g. 'swiss_roll' vs
|
||||||
|
'swiss_roll_hole' — by matching (path, cleaned kwargs) against
|
||||||
|
DATASET_META. Falls back to the path short-name when no match."""
|
||||||
|
gen_short = (generator_path or "").rsplit(".", 1)[-1]
|
||||||
|
gk = _clean_gen_kwargs(generator_kwargs)
|
||||||
|
candidates = [
|
||||||
|
(k, m) for k, m in DATASET_META.items()
|
||||||
|
if m["path"].rsplit(".", 1)[-1] == gen_short
|
||||||
|
]
|
||||||
|
if not candidates:
|
||||||
|
return gen_short
|
||||||
|
if gk is not None:
|
||||||
|
for k, m in candidates:
|
||||||
|
if m["kwargs"] == gk:
|
||||||
|
return k
|
||||||
|
return candidates[0][0]
|
||||||
|
|
||||||
|
|
||||||
def _lookup_dataset_meta(
|
def _lookup_dataset_meta(
|
||||||
generator_short: str, generator_kwargs: Optional[Dict[str, Any]]
|
generator_short: str, generator_kwargs: Optional[Dict[str, Any]]
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
@ -926,9 +1068,10 @@ def _lookup_dataset_meta(
|
|||||||
]
|
]
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return None
|
return None
|
||||||
if generator_kwargs is not None:
|
gk = _clean_gen_kwargs(generator_kwargs)
|
||||||
|
if gk is not None:
|
||||||
for m in candidates:
|
for m in candidates:
|
||||||
if m["kwargs"] == generator_kwargs:
|
if m["kwargs"] == gk:
|
||||||
return m
|
return m
|
||||||
return candidates[0]
|
return candidates[0]
|
||||||
|
|
||||||
@ -963,7 +1106,13 @@ def _enrich_with_labels(d: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
dm = _lookup_dataset_meta(gen_short, gk)
|
dm = _lookup_dataset_meta(gen_short, gk)
|
||||||
if not dm:
|
if not dm:
|
||||||
return d
|
return d
|
||||||
kwargs_to_use = gk if gk is not None else dm["kwargs"]
|
# Replace the stem-derived generator short (ambiguous for swiss_roll vs
|
||||||
|
# hole) with the matched DATASET_META id for the panel header.
|
||||||
|
for key, entry in DATASET_META.items():
|
||||||
|
if entry is dm:
|
||||||
|
d["meta"]["generator"] = key
|
||||||
|
break
|
||||||
|
kwargs_to_use = _clean_gen_kwargs(gk) if gk is not None else dm["kwargs"]
|
||||||
try:
|
try:
|
||||||
mod_path, cls_name = dm["path"].rsplit(".", 1)
|
mod_path, cls_name = dm["path"].rsplit(".", 1)
|
||||||
fn = getattr(importlib.import_module(mod_path), cls_name)
|
fn = getattr(importlib.import_module(mod_path), cls_name)
|
||||||
|
|||||||
@ -20,7 +20,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
_STEM_RE = re.compile(
|
_STEM_RE = re.compile(
|
||||||
r"^(?P<gen>make_.+?)_(?P<emb>[A-Za-z]+)_N(?P<n>\d+)_T(?P<t>\d+)"
|
r"^(?P<gen>make_.+?)_(?P<emb>[A-Za-z]+)_N(?P<n>\d+)_T(?P<t>\d+)"
|
||||||
r"_J(?P<j>[\d.]+)_s(?P<s>\d+)(?:_(?P<h>[0-9a-f]{8}))?$"
|
r"_J(?P<j>[\d.Ee+\-]+)_s(?P<s>\d+)(?:_(?P<h>[0-9a-f]{8}))?$"
|
||||||
)
|
)
|
||||||
|
|
||||||
# plotly's typed-array dtype -> (struct format char, item size bytes)
|
# plotly's typed-array dtype -> (struct format char, item size bytes)
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
const selected = new Set();
|
const selected = new Set();
|
||||||
|
|
||||||
const btn = document.getElementById('compare-btn');
|
const btn = document.getElementById('compare-btn');
|
||||||
|
const clearBtn = document.getElementById('compare-clear');
|
||||||
const countEl = document.getElementById('compare-count');
|
const countEl = document.getElementById('compare-count');
|
||||||
const slot = document.getElementById('runs-slot');
|
const slot = document.getElementById('runs-slot');
|
||||||
if (!btn || !countEl || !slot) return;
|
if (!btn || !countEl || !slot) return;
|
||||||
@ -16,17 +17,15 @@
|
|||||||
const n = selected.size;
|
const n = selected.size;
|
||||||
countEl.textContent = `(${n}/${MAX})`;
|
countEl.textContent = `(${n}/${MAX})`;
|
||||||
btn.disabled = n < MIN || n > MAX;
|
btn.disabled = n < MIN || n > MAX;
|
||||||
|
if (clearBtn) clearBtn.hidden = n === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyToDOM() {
|
function applyToDOM() {
|
||||||
const checkboxes = slot.querySelectorAll('.compare-cb');
|
// Selections persist across swaps — with server-side filtering, rows
|
||||||
// Drop any selected stems that are no longer in the DOM (run aged out of list)
|
// leave the DOM when they don't match the current filter, but the user
|
||||||
const present = new Set();
|
// still has them "in the cart".
|
||||||
checkboxes.forEach((cb) => present.add(cb.dataset.stem));
|
|
||||||
for (const s of [...selected]) if (!present.has(s)) selected.delete(s);
|
|
||||||
|
|
||||||
const atCap = selected.size >= MAX;
|
const atCap = selected.size >= MAX;
|
||||||
checkboxes.forEach((cb) => {
|
slot.querySelectorAll('.compare-cb').forEach((cb) => {
|
||||||
const stem = cb.dataset.stem;
|
const stem = cb.dataset.stem;
|
||||||
cb.checked = selected.has(stem);
|
cb.checked = selected.has(stem);
|
||||||
cb.disabled = atCap && !cb.checked;
|
cb.disabled = atCap && !cb.checked;
|
||||||
@ -61,5 +60,10 @@
|
|||||||
window.open(`/compare?${qs}`, '_blank', 'noopener');
|
window.open(`/compare?${qs}`, '_blank', 'noopener');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (clearBtn) clearBtn.addEventListener('click', () => {
|
||||||
|
selected.clear();
|
||||||
|
applyToDOM();
|
||||||
|
});
|
||||||
|
|
||||||
applyToDOM();
|
applyToDOM();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// compare.js — thin shim that parses ?stem=…&stem=… (legacy ?a=&b=) and
|
// compare.js — thin shim that parses ?stem=…&stem=… (legacy ?a=&b=) and
|
||||||
// hands off to panel-grid.js.
|
// hands off to panel-grid.js.
|
||||||
|
|
||||||
import { mountPanels } from './panel-grid.js?v=5';
|
import { mountPanels } from './panel-grid.js?v=6';
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
let stems = params.getAll('stem').filter(Boolean);
|
let stems = params.getAll('stem').filter(Boolean);
|
||||||
|
|||||||
@ -567,6 +567,9 @@ export function mountPanels({ host, controls, stems }) {
|
|||||||
// Reset controls to a clean state (they may be reused across opens).
|
// Reset controls to a clean state (they may be reused across opens).
|
||||||
if (scrub) scrub.value = '0';
|
if (scrub) scrub.value = '0';
|
||||||
if (playBtn) { playBtn.textContent = '▶'; playBtn.setAttribute('aria-label', 'play'); }
|
if (playBtn) { playBtn.textContent = '▶'; playBtn.setAttribute('aria-label', 'play'); }
|
||||||
|
// Axes dropdown is meaningless for a single-panel view (nothing to lock
|
||||||
|
// against; aspect is fixed by the modal dialog's own sizing).
|
||||||
|
if (syncSel) syncSel.disabled = total < 2;
|
||||||
|
|
||||||
// Parallel fetch; each panel's error is independent.
|
// Parallel fetch; each panel's error is independent.
|
||||||
const ready = Promise.allSettled(stems.map(fetchFrames)).then((results) => {
|
const ready = Promise.allSettled(stems.map(fetchFrames)).then((results) => {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// run-modal.js — homepage click-hijack for embedding links. Opens a
|
// run-modal.js — homepage click-hijack for embedding links. Opens a
|
||||||
// <dialog id="run-modal"> that renders the run's embedding via panel-grid.js.
|
// <dialog id="run-modal"> that renders the run's embedding via panel-grid.js.
|
||||||
|
|
||||||
import { mountPanels } from './panel-grid.js?v=5';
|
import { mountPanels } from './panel-grid.js?v=6';
|
||||||
|
|
||||||
const dialog = document.getElementById('run-modal');
|
const dialog = document.getElementById('run-modal');
|
||||||
const host = document.getElementById('modal-panel-host');
|
const host = document.getElementById('modal-panel-host');
|
||||||
|
|||||||
@ -1,114 +1,123 @@
|
|||||||
// Filter the recent-runs list by dataset + algorithm chips.
|
// Cassette-style single-select chip filter. Each axis has at most one
|
||||||
// State lives outside #runs-slot so it survives the 3s htmx poll. After
|
// selection; clicking the selected chip again releases it. State lives
|
||||||
// each swap we repopulate chip options from whatever runs came back, then
|
// here and rides along on the htmx-polled /runs request via hx-vals on
|
||||||
// re-apply the current selection to hide non-matching rows.
|
// #runs-slot. Chip universe comes from /runs/axes.json so the bar shows
|
||||||
|
// the full history, not just the currently-displayed page.
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
const slot = document.getElementById('runs-slot');
|
const slot = document.getElementById('runs-slot');
|
||||||
const dsEl = document.getElementById('runs-flt-dataset');
|
if (!slot) return;
|
||||||
const algEl = document.getElementById('runs-flt-algo');
|
|
||||||
if (!slot || !dsEl || !algEl) return;
|
|
||||||
|
|
||||||
// null = "all selected" (no filtering on this axis). Populated Sets
|
const AXES = [
|
||||||
// override that. Sticky across htmx swaps.
|
{ key: 'dataset', chipsId: 'runs-flt-dataset', numeric: false },
|
||||||
let datasets = null;
|
{ key: 'algorithm', chipsId: 'runs-flt-algo', numeric: false },
|
||||||
let algorithms = null;
|
{ key: 'N', chipsId: 'runs-flt-n', numeric: true },
|
||||||
|
{ key: 'T', chipsId: 'runs-flt-t', numeric: true },
|
||||||
|
{ key: 'J', chipsId: 'runs-flt-j', numeric: true },
|
||||||
|
];
|
||||||
|
|
||||||
function scanValues() {
|
for (const ax of AXES) {
|
||||||
const ds = new Set();
|
ax.el = document.getElementById(ax.chipsId);
|
||||||
const alg = new Set();
|
ax.group = ax.el ? ax.el.closest('.runs-filter-group') : null;
|
||||||
slot.querySelectorAll('li.run').forEach((li) => {
|
ax.selected = null;
|
||||||
const d = li.dataset.generator; if (d) ds.add(d);
|
ax.universe = [];
|
||||||
const a = li.dataset.embedder; if (a) alg.add(a);
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
datasets: [...ds].sort(),
|
|
||||||
algorithms: [...alg].sort(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function paint(container, values, selected) {
|
function stateAsQuery() {
|
||||||
container.innerHTML = '';
|
const p = {};
|
||||||
|
for (const ax of AXES) {
|
||||||
|
if (ax.selected != null) p[ax.key] = ax.selected;
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncHtmxVals() {
|
||||||
|
// Feed the current chip state into every htmx request on #runs-slot
|
||||||
|
// (including the 3s poll). JSON form.
|
||||||
|
slot.setAttribute('hx-vals', JSON.stringify(stateAsQuery()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function paint(ax) {
|
||||||
|
if (!ax.el) return;
|
||||||
|
ax.el.innerHTML = '';
|
||||||
|
const values = ax.universe;
|
||||||
|
if (ax.group) ax.group.style.display = values.length <= 1 ? 'none' : '';
|
||||||
for (const v of values) {
|
for (const v of values) {
|
||||||
const b = document.createElement('button');
|
const b = document.createElement('button');
|
||||||
b.type = 'button';
|
b.type = 'button';
|
||||||
const on = selected == null || selected.has(v);
|
const on = ax.selected === v;
|
||||||
b.className = 'chip' + (on ? ' is-on' : '');
|
b.className = 'chip' + (on ? ' is-on' : '');
|
||||||
b.dataset.value = v;
|
b.dataset.value = v;
|
||||||
b.dataset.role = 'value';
|
|
||||||
b.setAttribute('aria-pressed', on ? 'true' : 'false');
|
b.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||||
b.textContent = v;
|
b.textContent = v;
|
||||||
container.appendChild(b);
|
ax.el.appendChild(b);
|
||||||
}
|
|
||||||
for (const [role, label] of [['all', 'all'], ['none', 'none']]) {
|
|
||||||
const b = document.createElement('button');
|
|
||||||
b.type = 'button';
|
|
||||||
b.className = 'chip chip-meta';
|
|
||||||
b.dataset.role = role;
|
|
||||||
b.textContent = label;
|
|
||||||
container.appendChild(b);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function repaint() {
|
function repaintAll() {
|
||||||
const { datasets: allDs, algorithms: allAlg } = scanValues();
|
for (const ax of AXES) paint(ax);
|
||||||
paint(dsEl, allDs, datasets);
|
|
||||||
paint(algEl, allAlg, algorithms);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function apply() {
|
async function refreshUniverse() {
|
||||||
slot.querySelectorAll('li.run').forEach((li) => {
|
try {
|
||||||
const ds = li.dataset.generator || '';
|
const res = await fetch('/runs/axes.json', { cache: 'no-store' });
|
||||||
const al = li.dataset.embedder || '';
|
if (!res.ok) return;
|
||||||
const passDs = datasets == null || datasets.has(ds);
|
const data = await res.json();
|
||||||
const passAl = algorithms == null || algorithms.has(al);
|
for (const ax of AXES) {
|
||||||
li.classList.toggle('filtered-out', !(passDs && passAl));
|
ax.universe = Array.isArray(data[ax.key]) ? data[ax.key] : [];
|
||||||
});
|
}
|
||||||
|
repaintAll();
|
||||||
|
} catch {
|
||||||
|
/* offline → leave whatever we had */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function bind(container, getSet, setSet, allGetter) {
|
function anyFilterActive() {
|
||||||
container.addEventListener('click', (e) => {
|
return AXES.some((ax) => ax.selected != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCounter() {
|
||||||
|
const count = slot.querySelectorAll('li.run').length;
|
||||||
|
const cap = anyFilterActive() ? 50 : 10;
|
||||||
|
const countEl = document.getElementById('runs-count');
|
||||||
|
const capEl = document.getElementById('runs-cap');
|
||||||
|
if (countEl) countEl.textContent = String(count);
|
||||||
|
if (capEl) capEl.textContent = String(cap);
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerRunsRefresh() {
|
||||||
|
// Tell htmx to re-fetch /runs right now with the updated hx-vals.
|
||||||
|
if (window.htmx && typeof window.htmx.trigger === 'function') {
|
||||||
|
window.htmx.trigger(slot, 'filter-changed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ax of AXES) {
|
||||||
|
if (!ax.el) continue;
|
||||||
|
ax.el.addEventListener('click', (e) => {
|
||||||
const btn = e.target.closest('.chip');
|
const btn = e.target.closest('.chip');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
const role = btn.dataset.role;
|
|
||||||
const all = allGetter();
|
|
||||||
let cur = getSet();
|
|
||||||
if (cur == null) cur = new Set(all);
|
|
||||||
if (role === 'value') {
|
|
||||||
const v = btn.dataset.value;
|
const v = btn.dataset.value;
|
||||||
if (cur.has(v)) cur.delete(v);
|
ax.selected = (ax.selected === v) ? null : v;
|
||||||
else cur.add(v);
|
paint(ax);
|
||||||
} else if (role === 'all') {
|
syncHtmxVals();
|
||||||
cur = new Set(all);
|
updateCounter();
|
||||||
} else if (role === 'none') {
|
triggerRunsRefresh();
|
||||||
cur = new Set();
|
|
||||||
}
|
|
||||||
setSet(cur);
|
|
||||||
repaint();
|
|
||||||
apply();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
bind(
|
// Re-paint on htmx swap (fresh runs arriving) so the chip universe stays
|
||||||
dsEl,
|
// current even between explicit refreshes.
|
||||||
() => datasets,
|
|
||||||
(s) => { datasets = s; },
|
|
||||||
() => scanValues().datasets,
|
|
||||||
);
|
|
||||||
bind(
|
|
||||||
algEl,
|
|
||||||
() => algorithms,
|
|
||||||
(s) => { algorithms = s; },
|
|
||||||
() => scanValues().algorithms,
|
|
||||||
);
|
|
||||||
|
|
||||||
document.body.addEventListener('htmx:afterSwap', (e) => {
|
document.body.addEventListener('htmx:afterSwap', (e) => {
|
||||||
if (e.target && e.target.id === 'runs-slot') {
|
if (e.target && e.target.id === 'runs-slot') {
|
||||||
repaint();
|
repaintAll();
|
||||||
apply();
|
updateCounter();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
repaint();
|
syncHtmxVals();
|
||||||
apply();
|
refreshUniverse();
|
||||||
|
updateCounter();
|
||||||
|
// Periodically refresh the universe so newly-introduced values appear.
|
||||||
|
setInterval(refreshUniverse, 30_000);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@ -513,15 +513,37 @@ button.submit:disabled { background: var(--faint); border-color: var(--faint); c
|
|||||||
font-size: 0.76rem;
|
font-size: 0.76rem;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
.compare-bar .compare-clear {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: var(--faint);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 0 0.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline dotted;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
.compare-bar .compare-clear:hover { color: var(--alarm); }
|
||||||
|
|
||||||
.runs-filter {
|
.runs-filter {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-direction: column;
|
||||||
gap: 0.6rem 1.4rem;
|
gap: 0.4rem;
|
||||||
padding: 0.25rem 0 0.7rem;
|
padding: 0.25rem 0 0.7rem;
|
||||||
margin-bottom: 0.3rem;
|
margin-bottom: 0.3rem;
|
||||||
border-bottom: 1px dashed var(--rule);
|
border-bottom: 1px dashed var(--rule);
|
||||||
}
|
}
|
||||||
|
.runs-filter-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem 1.4rem;
|
||||||
|
}
|
||||||
|
/* Hide a row entirely when every child group is display:none (all axes
|
||||||
|
in it have a single value). :has is supported in all modern evergreens. */
|
||||||
|
.runs-filter-row:not(:has(.runs-filter-group:not([style*="display: none"]))) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.runs-filter-group {
|
.runs-filter-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -542,6 +564,11 @@ button.submit:disabled { background: var(--faint); border-color: var(--faint); c
|
|||||||
gap: 0.25rem 0.3rem;
|
gap: 0.25rem 0.3rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
.runs-filter-group .chips .chip-meta-wrap {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.25rem 0.3rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.runs li.run.filtered-out { display: none; }
|
.runs li.run.filtered-out { display: none; }
|
||||||
.runs li.run.just-submitted {
|
.runs li.run.just-submitted {
|
||||||
@ -1680,6 +1707,9 @@ button.submit:disabled { background: var(--faint); border-color: var(--faint); c
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
|
/* Hide a wrap entirely when its select is disabled (e.g., axes on a
|
||||||
|
single-panel modal view). */
|
||||||
|
.compare-controls label:has(select:disabled) { display: none; }
|
||||||
.compare-controls .cc-lbl {
|
.compare-controls .cc-lbl {
|
||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
text-transform: lowercase;
|
text-transform: lowercase;
|
||||||
|
|||||||
@ -9,7 +9,10 @@
|
|||||||
{% for r in runs %}
|
{% for r in runs %}
|
||||||
<li class="run {% if just_submitted is defined and r.id == just_submitted %}just-submitted{% endif %}{% if r.stale %} stale{% endif %}"
|
<li class="run {% if just_submitted is defined and r.id == just_submitted %}just-submitted{% endif %}{% if r.stale %} stale{% endif %}"
|
||||||
data-embedder="{{ r.embedder_short or '' }}"
|
data-embedder="{{ r.embedder_short or '' }}"
|
||||||
data-generator="{{ r.generator_short or '' }}">
|
data-generator="{{ r.generator_short or '' }}"
|
||||||
|
data-n="{{ r.params.get('num_points', '') if r.params else '' }}"
|
||||||
|
data-t="{{ r.params.get('num_timesteps', r.params.get('num_snapshots', '')) if r.params else '' }}"
|
||||||
|
data-j="{{ r.params.get('jitter_scale', '') if r.params else '' }}">
|
||||||
{% if r.emb_exists and not r.stale %}
|
{% if r.emb_exists and not r.stale %}
|
||||||
<input type="checkbox" class="compare-cb" data-stem="{{ r.emb_file[:-5] }}" aria-label="select run for comparison" />
|
<input type="checkbox" class="compare-cb" data-stem="{{ r.emb_file[:-5] }}" aria-label="select run for comparison" />
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>embedding notebook · compare</title>
|
<title>embedding notebook · compare</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=32" />
|
<link rel="stylesheet" href="/static/style.css?v=36" />
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
"imports": {
|
"imports": {
|
||||||
@ -102,7 +102,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script src="/static/theme.js?v=11"></script>
|
<script src="/static/theme.js?v=11"></script>
|
||||||
<script type="module" src="/static/compare.js?v=17"></script>
|
<script type="module" src="/static/compare.js?v=18"></script>
|
||||||
<!-- panel-grid.js is imported by compare.js (module); versioned via compare.js cache-bust -->
|
<!-- panel-grid.js is imported by compare.js (module); versioned via compare.js cache-bust -->
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>embedding notebook</title>
|
<title>embedding notebook</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=32" />
|
<link rel="stylesheet" href="/static/style.css?v=38" />
|
||||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
@ -296,7 +296,7 @@
|
|||||||
<div class="section-label">
|
<div class="section-label">
|
||||||
<span>§ 4 recent runs</span>
|
<span>§ 4 recent runs</span>
|
||||||
<span class="run-count">
|
<span class="run-count">
|
||||||
<span id="runs-count">{{ runs|length }}</span> / 10 · refresh 3s
|
<span id="runs-count">{{ runs|length }}</span> / <span id="runs-cap">10</span> · refresh 3s
|
||||||
<span id="poll-ind" class="htmx-indicator" style="margin-left:6px">●</span>
|
<span id="poll-ind" class="htmx-indicator" style="margin-left:6px">●</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -305,24 +305,41 @@
|
|||||||
<button type="button" id="compare-btn" disabled>
|
<button type="button" id="compare-btn" disabled>
|
||||||
compare selected <span id="compare-count">(0/8)</span>
|
compare selected <span id="compare-count">(0/8)</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" id="compare-clear" class="compare-clear" hidden aria-label="clear selection">clear</button>
|
||||||
<span class="compare-hint muted">pick 2–8 embeddings → side-by-side animation in a new tab</span>
|
<span class="compare-hint muted">pick 2–8 embeddings → side-by-side animation in a new tab</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="runs-filter" id="runs-filter">
|
<div class="runs-filter" id="runs-filter">
|
||||||
<div class="runs-filter-group">
|
<div class="runs-filter-row">
|
||||||
|
<div class="runs-filter-group" data-axis="dataset">
|
||||||
<span class="ctl-label">dataset</span>
|
<span class="ctl-label">dataset</span>
|
||||||
<div class="chips" id="runs-flt-dataset" aria-label="filter by dataset"></div>
|
<div class="chips" id="runs-flt-dataset" aria-label="filter by dataset"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="runs-filter-group">
|
<div class="runs-filter-group" data-axis="algorithm">
|
||||||
<span class="ctl-label">algorithm</span>
|
<span class="ctl-label">algorithm</span>
|
||||||
<div class="chips" id="runs-flt-algo" aria-label="filter by algorithm"></div>
|
<div class="chips" id="runs-flt-algo" aria-label="filter by algorithm"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="runs-filter-row">
|
||||||
|
<div class="runs-filter-group" data-axis="n">
|
||||||
|
<span class="ctl-label">N</span>
|
||||||
|
<div class="chips" id="runs-flt-n" aria-label="filter by N"></div>
|
||||||
|
</div>
|
||||||
|
<div class="runs-filter-group" data-axis="t">
|
||||||
|
<span class="ctl-label">T</span>
|
||||||
|
<div class="chips" id="runs-flt-t" aria-label="filter by T"></div>
|
||||||
|
</div>
|
||||||
|
<div class="runs-filter-group" data-axis="j">
|
||||||
|
<span class="ctl-label">J</span>
|
||||||
|
<div class="chips" id="runs-flt-j" aria-label="filter by J"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
id="runs-slot"
|
id="runs-slot"
|
||||||
hx-get="/runs"
|
hx-get="/runs"
|
||||||
hx-trigger="load delay:3s, every 3s"
|
hx-trigger="load delay:3s, every 3s, filter-changed"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-indicator="#poll-ind"
|
hx-indicator="#poll-ind"
|
||||||
>
|
>
|
||||||
@ -484,9 +501,9 @@
|
|||||||
<script src="/static/theme.js?v=11"></script>
|
<script src="/static/theme.js?v=11"></script>
|
||||||
<script type="module" src="/static/dataset-picker.js?v=11"></script>
|
<script type="module" src="/static/dataset-picker.js?v=11"></script>
|
||||||
<script type="module" src="/static/metrics.js?v=11"></script>
|
<script type="module" src="/static/metrics.js?v=11"></script>
|
||||||
<script src="/static/compare-select.js?v=2"></script>
|
<script src="/static/compare-select.js?v=4"></script>
|
||||||
<script src="/static/runs-filter.js?v=1"></script>
|
<script src="/static/runs-filter.js?v=6"></script>
|
||||||
<script type="module" src="/static/run-modal.js?v=2"></script>
|
<script type="module" src="/static/run-modal.js?v=3"></script>
|
||||||
<script>
|
<script>
|
||||||
// Anchor-links alone don't expand <details>; force it.
|
// Anchor-links alone don't expand <details>; force it.
|
||||||
document.querySelector('a[href="#metrics"]')?.addEventListener('click', () => {
|
document.querySelector('a[href="#metrics"]')?.addEventListener('click', () => {
|
||||||
|
|||||||
@ -43,6 +43,19 @@ def _run_args_hash(
|
|||||||
return hashlib.sha1(s.encode()).hexdigest()[:8]
|
return hashlib.sha1(s.encode()).hexdigest()[:8]
|
||||||
|
|
||||||
|
|
||||||
|
def _sci(v: Any) -> str:
|
||||||
|
"""Float → compact sci notation without a period (e.g. 0.005 → 5E-3,
|
||||||
|
0.01 → 1E-2). Keeps Prefect's UI happy — it doesn't like periods in
|
||||||
|
run names."""
|
||||||
|
try:
|
||||||
|
f = float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return str(v)
|
||||||
|
m, e = f"{f:.3e}".split("e")
|
||||||
|
m = m.rstrip("0").rstrip(".")
|
||||||
|
return f"{m}E{int(e)}"
|
||||||
|
|
||||||
|
|
||||||
def _flow_run_name() -> str:
|
def _flow_run_name() -> str:
|
||||||
"""Name each Prefect run after the stem of its output fig, so runs are
|
"""Name each Prefect run after the stem of its output fig, so runs are
|
||||||
searchable / hoverable instead of wearing Prefect's auto-generated
|
searchable / hoverable instead of wearing Prefect's auto-generated
|
||||||
@ -52,7 +65,7 @@ def _flow_run_name() -> str:
|
|||||||
emb = (p.get("embedder") or "").rsplit(".", 1)[-1] or "?"
|
emb = (p.get("embedder") or "").rsplit(".", 1)[-1] or "?"
|
||||||
N = p.get("num_points", "?")
|
N = p.get("num_points", "?")
|
||||||
T = p.get("num_timesteps", "?")
|
T = p.get("num_timesteps", "?")
|
||||||
J = p.get("jitter_scale", "?")
|
J = _sci(p.get("jitter_scale", "?"))
|
||||||
s = p.get("seed", "?")
|
s = p.get("seed", "?")
|
||||||
tag = _run_args_hash(p.get("embed_args"), p.get("generator_kwargs"))
|
tag = _run_args_hash(p.get("embed_args"), p.get("generator_kwargs"))
|
||||||
return f"{gen}_{emb}_N{N}_T{T}_J{J}_s{s}_{tag}"
|
return f"{gen}_{emb}_N{N}_T{T}_J{J}_s{s}_{tag}"
|
||||||
@ -315,12 +328,13 @@ def embedding_flow(
|
|||||||
|
|
||||||
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
||||||
_generator = generator_path.split(".")[-1]
|
_generator = generator_path.split(".")[-1]
|
||||||
|
_j = _sci(jitter_scale)
|
||||||
output_ref: str = (
|
output_ref: str = (
|
||||||
f"{output_dir.strip('/')}/{_generator}_Reference_N{num_points}_T{num_timesteps}_J{jitter_scale}_s{seed}.html"
|
f"{output_dir.strip('/')}/{_generator}_Reference_N{num_points}_T{num_timesteps}_J{_j}_s{seed}.html"
|
||||||
)
|
)
|
||||||
_args_tag = _run_args_hash(embed_args, user_generator_kwargs)
|
_args_tag = _run_args_hash(embed_args, user_generator_kwargs)
|
||||||
output_embed: str = (
|
output_embed: str = (
|
||||||
f"{output_dir.strip('/')}/{_generator}_{embedder.split('.')[-1]}_N{num_points}_T{num_timesteps}_J{jitter_scale}_s{seed}_{_args_tag}.html"
|
f"{output_dir.strip('/')}/{_generator}_{embedder.split('.')[-1]}_N{num_points}_T{num_timesteps}_J{_j}_s{seed}_{_args_tag}.html"
|
||||||
)
|
)
|
||||||
output_metrics: str = output_embed[:-5] + ".metrics.json"
|
output_metrics: str = output_embed[:-5] + ".metrics.json"
|
||||||
output_frames: str = output_embed[:-5] + ".frames.json"
|
output_frames: str = output_embed[:-5] + ".frames.json"
|
||||||
|
|||||||
@ -30,7 +30,7 @@ from typing import Any, Dict, List, Optional
|
|||||||
|
|
||||||
_ROOT = Path(__file__).resolve().parent.parent
|
_ROOT = Path(__file__).resolve().parent.parent
|
||||||
sys.path.insert(0, str(_ROOT))
|
sys.path.insert(0, str(_ROOT))
|
||||||
from app.web.main import PREFECT, run_args_hash # noqa: E402
|
from app.web.main import PREFECT, run_args_hash, sci_notation # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def _legacy_hash(ea: Optional[Dict[str, Any]]) -> str:
|
def _legacy_hash(ea: Optional[Dict[str, Any]]) -> str:
|
||||||
@ -38,30 +38,45 @@ def _legacy_hash(ea: Optional[Dict[str, Any]]) -> str:
|
|||||||
return hashlib.sha1(s.encode()).hexdigest()[:8]
|
return hashlib.sha1(s.encode()).hexdigest()[:8]
|
||||||
|
|
||||||
|
|
||||||
def _base_stem(params: Dict[str, Any]) -> Optional[str]:
|
def _base_stems(params: Dict[str, Any]) -> List[str]:
|
||||||
|
"""Return the stem prefix(es) for this run's params: both the current
|
||||||
|
sci-J form and the legacy decimal-J form, so we can find pre-transition
|
||||||
|
files on disk too."""
|
||||||
try:
|
try:
|
||||||
gen = (params.get("generator_path") or "").rsplit(".", 1)[-1]
|
gen = (params.get("generator_path") or "").rsplit(".", 1)[-1]
|
||||||
emb = (params.get("embedder") or "").rsplit(".", 1)[-1]
|
emb = (params.get("embedder") or "").rsplit(".", 1)[-1]
|
||||||
N = int(params["num_points"])
|
N = int(params["num_points"])
|
||||||
T = int(params.get("num_timesteps", params.get("num_snapshots")))
|
T = int(params.get("num_timesteps", params.get("num_snapshots")))
|
||||||
J = float(params["jitter_scale"])
|
Jf = float(params["jitter_scale"])
|
||||||
s = int(params["seed"])
|
s = int(params["seed"])
|
||||||
except (KeyError, TypeError, ValueError):
|
except (KeyError, TypeError, ValueError):
|
||||||
return None
|
return []
|
||||||
if not gen or not emb:
|
if not gen or not emb:
|
||||||
return None
|
return []
|
||||||
return f"{gen}_{emb}_N{N}_T{T}_J{J}_s{s}"
|
out = [f"{gen}_{emb}_N{N}_T{T}_J{sci_notation(Jf)}_s{s}"]
|
||||||
|
legacy = f"{gen}_{emb}_N{N}_T{T}_J{Jf}_s{s}"
|
||||||
|
if legacy not in out:
|
||||||
|
out.append(legacy)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _candidate_names(base: str, ea: Dict[str, Any], gk: Dict[str, Any]) -> List[str]:
|
def _candidate_names(bases: List[str], ea: Dict[str, Any], gk: Dict[str, Any]) -> List[str]:
|
||||||
target = f"{base}_{run_args_hash(ea, gk)}.html"
|
# Target = current sci-J base + new-scheme hash.
|
||||||
legacy = f"{base}_{_legacy_hash(ea)}.html"
|
if not bases:
|
||||||
no_hash = f"{base}.html"
|
return []
|
||||||
# Preserve order: target first so we short-circuit on already-backfilled.
|
target_base = bases[0]
|
||||||
|
target = f"{target_base}_{run_args_hash(ea, gk)}.html"
|
||||||
out = [target]
|
out = [target]
|
||||||
for x in (legacy, no_hash):
|
# Fall back to every (base, hash) combination we might find on disk.
|
||||||
|
hashes = [run_args_hash(ea, gk), _legacy_hash(ea)]
|
||||||
|
for b in bases:
|
||||||
|
for h in hashes:
|
||||||
|
x = f"{b}_{h}.html"
|
||||||
if x not in out:
|
if x not in out:
|
||||||
out.append(x)
|
out.append(x)
|
||||||
|
no_hash = f"{b}.html"
|
||||||
|
if no_hash not in out:
|
||||||
|
out.append(no_hash)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@ -125,13 +140,13 @@ def main() -> int:
|
|||||||
params = r.get("parameters") or {}
|
params = r.get("parameters") or {}
|
||||||
ea = params.get("embed_args") or {}
|
ea = params.get("embed_args") or {}
|
||||||
gk = params.get("generator_kwargs") or {}
|
gk = params.get("generator_kwargs") or {}
|
||||||
base = _base_stem(params)
|
bases = _base_stems(params)
|
||||||
if not base:
|
if not bases:
|
||||||
continue
|
continue
|
||||||
target = f"{base}_{run_args_hash(ea, gk)}.html"
|
target = f"{bases[0]}_{run_args_hash(ea, gk)}.html"
|
||||||
if target in seen_targets:
|
if target in seen_targets:
|
||||||
continue # later duplicate — the stale-marking logic will handle it
|
continue # later duplicate — the stale-marking logic will handle it
|
||||||
for candidate in _candidate_names(base, ea, gk):
|
for candidate in _candidate_names(bases, ea, gk):
|
||||||
if (figs_dir / candidate).exists():
|
if (figs_dir / candidate).exists():
|
||||||
if candidate == target:
|
if candidate == target:
|
||||||
# Already at target; just ensure metrics.json carries gk.
|
# Already at target; just ensure metrics.json carries gk.
|
||||||
|
|||||||
98
scripts/backfill_tags.py
Normal file
98
scripts/backfill_tags.py
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
"""Backfill per-run Prefect tags for the chip-filter UX.
|
||||||
|
|
||||||
|
Each run in the deployment is tagged with
|
||||||
|
dataset:<id> algorithm:<short> N:<n> T:<t> J:<j>
|
||||||
|
computed from its stored `parameters`. Existing tags on the run are
|
||||||
|
preserved; the five axis tags are merged in (replacing any stale value).
|
||||||
|
|
||||||
|
Dry-run by default. Pass `--apply` to actually PATCH runs.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
.venv/bin/python scripts/backfill_tags.py [--apply] [--limit N]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(_ROOT))
|
||||||
|
|
||||||
|
import httpx # noqa: E402
|
||||||
|
|
||||||
|
from app.web.main import PREFECT, TAG_AXES, build_run_tags # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _desired_tags(params: Dict[str, Any]) -> List[str]:
|
||||||
|
return build_run_tags(
|
||||||
|
params.get("generator_path") or "",
|
||||||
|
params.get("generator_kwargs") or {},
|
||||||
|
params.get("embedder") or "",
|
||||||
|
int(params.get("num_points", 0) or 0),
|
||||||
|
int(params.get("num_timesteps", params.get("num_snapshots", 0)) or 0),
|
||||||
|
float(params.get("jitter_scale", 0.0) or 0.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge(existing: List[str], desired: List[str]) -> List[str]:
|
||||||
|
"""Replace any existing `<axis>:*` tags with the desired ones; keep
|
||||||
|
anything else untouched."""
|
||||||
|
prefixes = tuple(f"{k}:" for k in TAG_AXES)
|
||||||
|
kept = [t for t in (existing or []) if not t.startswith(prefixes)]
|
||||||
|
return kept + list(desired)
|
||||||
|
|
||||||
|
|
||||||
|
async def main_async(apply: bool, limit: int) -> int:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as c:
|
||||||
|
runs = await PREFECT.recent_runs(c, limit=limit)
|
||||||
|
planned = []
|
||||||
|
for r in runs:
|
||||||
|
params = r.get("parameters") or {}
|
||||||
|
try:
|
||||||
|
desired = _desired_tags(params)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" skip {r['id'][:8]} ({e})")
|
||||||
|
continue
|
||||||
|
existing = r.get("tags") or []
|
||||||
|
merged = _merge(existing, desired)
|
||||||
|
if set(merged) == set(existing):
|
||||||
|
continue
|
||||||
|
planned.append((r["id"], existing, merged))
|
||||||
|
|
||||||
|
print(f"scanning deployment runs (seen: {len(runs)})")
|
||||||
|
print(f" {len(planned)} to patch\n")
|
||||||
|
for rid, _, merged in planned:
|
||||||
|
print(f" {rid[:8]} -> {sorted(merged)}")
|
||||||
|
if not planned:
|
||||||
|
print("nothing to do")
|
||||||
|
return 0
|
||||||
|
if not apply:
|
||||||
|
print("\n(dry run — pass --apply to patch)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print("\napplying...")
|
||||||
|
ok = 0
|
||||||
|
for rid, _, merged in planned:
|
||||||
|
if await PREFECT.update_tags(c, rid, merged):
|
||||||
|
ok += 1
|
||||||
|
print(f" {rid[:8]} OK")
|
||||||
|
else:
|
||||||
|
print(f" {rid[:8]} FAILED")
|
||||||
|
print(f"done — patched {ok}/{len(planned)}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__)
|
||||||
|
ap.add_argument("--apply", action="store_true", help="actually PATCH tags (default: dry-run)")
|
||||||
|
ap.add_argument("--limit", type=int, default=500, help="Prefect runs to scan")
|
||||||
|
args = ap.parse_args()
|
||||||
|
return asyncio.run(main_async(args.apply, args.limit))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Loading…
Reference in New Issue
Block a user