From 954e4997c824f7c6b5f6f5d53fb83580a4489bbc Mon Sep 17 00:00:00 2001 From: asgersvenning Date: Thu, 10 Sep 2026 22:06:35 +0200 Subject: [PATCH 01/34] docs: establish local worktree development workflow --- .gitignore | 2 ++ dev/README.md | 1 + dev/worktrees.md | 78 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 dev/worktrees.md diff --git a/.gitignore b/.gitignore index 118703b2..2e30fe0d 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,6 @@ examples/test.ipynb !/dev/ucloud/combined-int8.json !/dev/ucloud/figures.json /local-evidence/ +/.worktrees/ +/.venv !/dev/ucloud/ddp.json diff --git a/dev/README.md b/dev/README.md index ed5514e3..97c765cc 100644 --- a/dev/README.md +++ b/dev/README.md @@ -1,6 +1,7 @@ # Development checks Tests are grouped by subsystem; see the [test suite map](../tests/README.md). +Use [local worktrees](worktrees.md) to develop independent branches concurrently. For quantization work, follow the [branch roadmap](../docs/quantization-roadmap.md) and [benchmark command index](benchmarks/README.md). For a mounted global_lepi dataset on a manually allocated UCloud node, use the diff --git a/dev/worktrees.md b/dev/worktrees.md new file mode 100644 index 00000000..b8c56ebe --- /dev/null +++ b/dev/worktrees.md @@ -0,0 +1,78 @@ +# Parallel local development + +Use one Git worktree per active feature. Worktrees have independent tracked files, +indexes and checked-out branches; commits, refs and Git configuration are shared. +The current prototype exploration starts from `quant`, whose diagnostic functions +are the reference for that work. Choose the base explicitly for other features. + +From the main checkout: + +```bash +git worktree list +git worktree add -b feature/my-feature .worktrees/my-feature quant +``` + +`/.worktrees/` is ignored on branches carrying this guide. For older branches, +add `/.worktrees/` once to the main checkout's `.git/info/exclude` (local only, +shared by worktrees). Do not copy uncommitted edits into another branch unless +they belong to its task. Existing main-checkout changes stay in place. + +## Reuse dependencies without changing them + +When both branches use the same dependencies, a worktree can reuse the existing +environment through a `.venv` symlink. This shares installed packages; it does +not provide independent environments. No branch using that environment should +install, remove or synchronize packages while others are using it. + +From the main checkout, before entering the new worktree: + +```bash +ln -s "$PWD/.venv" .worktrees/my-feature/.venv +cd .worktrees/my-feature +export PYTHONPATH="$PWD" +CUDA_VISIBLE_DEVICES='' .venv/bin/python -c 'import mini_trainer; print(mini_trainer.__file__)' +bash dev/check.sh static +bash dev/check.sh test tests/utils/test_dendrogram.py +``` + +The import check must point into the new worktree. Set `PYTHONPATH` separately +in each worktree's shell: it also gives Python subprocesses the correct source +root. Prefer `.venv/bin/python -m ...` over installed console scripts, which can +otherwise resolve an editable installation in the original checkout. + +Do not run `uv sync`, `uv pip install` or ordinary `uv run` against a shared +environment. Use its executables directly, or `uv run --no-sync`. The shared +environment remains subject to the README's explicit PyTorch-backend rules. + +If a branch needs different dependencies, first unlink only its `.venv` symlink +(`test -L .venv && unlink .venv`), then create a separate environment using the +README's explicit installation command and intended CPU/CUDA backend. Never +delete the symlink target. Separate environments need their own package/import +verification; CPU validation does not establish GPU correctness. + +## Keep experiments isolated + +- Use a separate terminal/session per worktree and check `pwd` and + `git branch --show-current` before edits, checks or commits. +- Keep generated reports in that worktree's ignored `tmp/`. Pass existing large + checkpoints and datasets by absolute path rather than copying them into Git. +- Do not run multiple experiments against the same output directory or change a + shared dependency environment concurrently. GPU, RAM and disk resources remain + shared even though the source trees are separate. +- Stage explicit paths and inspect the staged diff. A worktree does not protect + another worktree from operations on shared branch refs or repository config. +- Commit focused work before merging or cherry-picking it. Do not switch the + main checkout away from ongoing work just to inspect a feature branch. + +When a feature is integrated and its worktree has no changes or needed outputs: + +```bash +git worktree list +git -C .worktrees/my-feature status --short +git worktree remove .worktrees/my-feature +``` + +Inspect and retain ignored reports before removal. Do not use `--force` to +discard work. Branch deletion is a separate decision after integration. + +Agents must also follow the [worktree coordination rules](../.agents/rules/worktrees.md). From fe133f348b905fbd0d5c0d689e10a611e8911b31 Mon Sep 17 00:00:00 2001 From: asgersvenning Date: Thu, 10 Sep 2026 22:06:48 +0200 Subject: [PATCH 02/34] agent: define parallel worktree ownership and integration rules --- .agents/README.md | 1 + .agents/rules/worktrees.md | 79 ++++++++++++++++++++++++++++++++++++++ AGENTS.md | 14 +++++++ 3 files changed, 94 insertions(+) create mode 100644 .agents/rules/worktrees.md diff --git a/.agents/README.md b/.agents/README.md index 8547bc80..e280d063 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -10,6 +10,7 @@ The root [AGENTS.md](../AGENTS.md) is the instruction entry point and takes prec | Location | Purpose | Read when | | --- | --- | --- | | [Contribution rule](rules/code-contribution.md) | Focused change/review guidance | Making repository changes | +| [Worktree coordination](rules/worktrees.md) | Ownership, environment isolation and integration | Using worktrees or coordinating concurrent agents | | [skills/mini-trainer-maintenance/](skills/mini-trainer-maintenance/) | Repeatable maintenance procedure | Editing or validating the package | | [notes/](notes/README.md) | Selected handoffs, decisions and research | A note matches the task | | `local/` (ignored) | Scratch plans, logs, session state and temporary experiments | Only in the current local workflow | diff --git a/.agents/rules/worktrees.md b/.agents/rules/worktrees.md new file mode 100644 index 00000000..f188b933 --- /dev/null +++ b/.agents/rules/worktrees.md @@ -0,0 +1,79 @@ +# Worktree coordination for agents + +Read this before worktree setup or concurrent agent work. Root +[AGENTS.md](../../AGENTS.md) takes precedence. Use the shared commands in +[dev/worktrees.md](../../dev/worktrees.md); do not build a second setup workflow. + +## Assignment and ownership + +- The coordinating agent records each active task's owner, absolute worktree + path, branch, base commit, allowed paths, expected deliverable and validation + boundary in its task assignment. Use existing agent messaging/task tracking; + do not invent a committed lock file or session registry. +- Each concurrent implementation task gets a separate branch and worktree. + Read-only review can inspect an existing worktree, but must account for files + changing while its owner works. Review a committed revision for stable evidence. +- Agree ownership of shared interfaces before splitting dependent work. Give + overlapping file changes to one owner or execute them sequentially. Worktrees + prevent accidental shared-file edits; they do not prevent semantic conflicts. +- A task assignment does not authorize unrelated cleanup, changing other agents' + scopes or starting additional agents. Follow the session's delegation rules. + +## Establish the checkout before working + +1. Inspect `git worktree list`, the assigned checkout's `git status --short`, + `git branch --show-current` and `git rev-parse HEAD`. Check that the branch and + base agree with the assignment. Preserve any existing changes. +2. Read that checkout's `AGENTS.md` and task-relevant guidance. Different branches + can have different code and rules; the main checkout is not authoritative for + a feature branch's implementation. +3. Use the assigned absolute path as `workdir` for every shell tool call. Use + absolute paths rooted there for file edits. Pass the path and branch in every + delegated task. A shell `cd` does not change future tool calls or other agents. +4. Before tests, verify imports resolve to the assigned source tree using the + documented environment check. Set `PYTHONPATH` to that tree for subprocesses. + A test against the main checkout is not validation of the feature worktree. + +## Shared resources are still shared + +- A shared `.venv` is for read-only dependency reuse. Do not install, uninstall, + synchronize or replace packages from any participating worktree while it is in + use. Use a separate environment for dependency changes, explicitly choosing the + intended PyTorch backend. Do not claim the shared environment matches a branch's + lockfile without checking it. +- Give each task its own ignored report, benchmark, log and temporary paths. Use + existing datasets/checkpoints read-only by absolute path; do not mutate them or + silently substitute another run's outputs. +- Coordinate GPU and large CPU/RAM jobs. Do not start competing performance runs + or report contended measurements as comparable uncontended benchmarks. Record + hardware, revision and environment for performance evidence. +- Git refs, configuration, object storage and some metadata are shared. Do not + reset/rebase another task's branch, run repository-wide cleanup, remove another + worktree, or change common Git settings without coordinating with its owner. + Do not force worktree removal or delete another agent's Git lock files. + +## Commit and hand off + +- Keep implementation commits on the assigned branch. Stage explicit owned paths; + inspect staged content and run the relevant repository checks. Keep `agent:` + instruction/note commits separate from source and developer-facing docs. +- Hand off the worktree path, branch, commit IDs, changed paths, exact checks and + results, remaining limitations and any generated artifacts needed for review. + Disclose uncommitted changes; do not describe a dirty checkout as the commit. +- Use `.agents/notes/` only for durable handoffs worth retaining beyond this task; + otherwise use task messages and ignored temporary material. + +## Integration and retirement + +- One designated integrator owns the target branch. Contributors do not merge or + cherry-pick into it concurrently. Wait for the owning agent to finish writing + before integrating its commit; avoid copying live working files between trees. +- Review the actual diff and dependencies, then merge or cherry-pick focused + commits. Resolve conflicts according to the intended behaviour, involving the + relevant owner when intent is unclear. Do not overwrite unrelated local work. +- Run the checks required by the combined change on the integration checkout. + Passing independent branch checks does not establish compatibility of the + merged result. Report any unverified combination explicitly. +- Confirm integration, inspect tracked/untracked status and retain needed ignored + artifacts before removing a worktree. Never use forced removal or branch deletion + to hide incomplete work. Do not delete shared datasets or a shared `.venv` target. diff --git a/AGENTS.md b/AGENTS.md index 73ebbb35..40f50e18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,20 @@ - Follow [.agents/README.md](.agents/README.md) for agent material and commit boundaries. Read only task-relevant notes and skills; they are not all startup context. +## Parallel work and worktrees + +- Before creating or using a linked worktree, or coordinating concurrent agents, + read [.agents/rules/worktrees.md](.agents/rules/worktrees.md). Setup commands are + in [dev/worktrees.md](dev/worktrees.md). +- Assign each concurrent implementation task one branch, one absolute worktree + path and a bounded write scope. Pass that path explicitly to tools and agents; + never assume their default working directory changed with yours. +- Preserve other worktrees and shared resources. Do not switch another agent's + branch, modify its files, or change a shared environment while it is in use. +- Only the designated integrator merges completed work into the target branch, + after reviewing commits and validating the combined result. Worktree setup is + not itself an instruction to spawn agents. + ## Priorities Follow the order in `docs/roadmap.md`: development safeguards; behavior-preserving From 42cef5cd27553cba7404d1f3bb1bc3d1deda26fb Mon Sep 17 00:00:00 2001 From: asgersvenning Date: Thu, 10 Sep 2026 22:37:03 +0200 Subject: [PATCH 03/34] feat: evaluate prototype heatmaps in float32 log space --- README.md | 3 + docs/prototype-diagnostics.md | 86 ++++++++++++++++++++ mini_trainer/logging/core.py | 2 +- mini_trainer/modeling/__init__.py | 3 +- mini_trainer/modeling/distance.py | 28 +++++++ mini_trainer/visualization/plot.py | 96 ++++++++++++++++++++--- tests/logging/test_logging_simple.py | 8 +- tests/utils/test_log_class_similarity.py | 99 ++++++++++++++++++++++++ 8 files changed, 310 insertions(+), 15 deletions(-) create mode 100644 docs/prototype-diagnostics.md create mode 100644 tests/utils/test_log_class_similarity.py diff --git a/README.md b/README.md index f9f1d8b8..6f16c8d5 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,9 @@ preloading; use uncached loading when you need that explicit bound. `mini_trainer` supports logging your training runs, including metrics, confusion matrices, and the probabilistic dendrogram, directly to [Weights & Biases](https://wandb.ai). +Class-matrix diagnostics use [log-domain probabilities](docs/prototype-diagnostics.md) +to retain small tails during evaluation logging. + To use this feature you must install `mini_trainer` with the `recommended` extras. See [Installation](#installation) for more information. 1. **Login to your wandb account**: diff --git a/docs/prototype-diagnostics.md b/docs/prototype-diagnostics.md new file mode 100644 index 00000000..9eb65021 --- /dev/null +++ b/docs/prototype-diagnostics.md @@ -0,0 +1,86 @@ +# Prototype diagnostics in log space + +Prototype diagnostics use the classifier's effective last-layer weights and the +existing dimension-dependent cosine-to-z transform. The log-domain path changes +how the resulting Gaussian quantities are evaluated and displayed; it does not +fit a new distribution or impose a different organization on the learned space. + +```python +from mini_trainer.modeling import class_log_similarity +from mini_trainer.visualization import plot_class_distance_matrix + +log_cdf = class_log_similarity(model) # log Phi(z), one matrix per level +log_tail = class_log_similarity(model, complement=True) # log Phi(-z) +figures = plot_class_distance_matrix(model, log_domain=True) +``` + +Numerical range and colour range are independent. For example, add +`log_range=(-8 * math.log(10), 0.0)` (with `import math`) to focus colours on +probabilities from `1e-8` to `1`. These are natural-log display bounds. Values +outside them receive endpoint colours; the computed matrix is not clipped. +Omit `log_range` for the full computed colour range. Small outliers can compress +the visually interesting region in that full-range view. + +`class_log_similarity` returns float32 natural logs on the weights' device. It +uses the existing z-score computation, disables any surrounding autocast for the +diagnostic, and evaluates `torch.special.log_ndtr` directly. The self-similarity +diagonal is exactly 0 in log-CDF and `-inf` in log-tail. No gradients or probability +floor are used. Quantized-training weights follow the existing dequantization +handling before the diagnostic, not a separate prototype interpretation. + +The complementary representation is useful for strongly aligned prototypes: +`log Phi(-z)` remains representable when `Phi(z)` rounds to 1. In contrast, even +directly evaluating `-log Phi(z)` can yield a distance too small for float32. Keep +log-tail values for analysis/rendering rather than exponentiating them back into +probabilities. Higher precision is useful as an independent reference, not required +by this path. See the [PyTorch definition of log_ndtr](https://docs.pytorch.org/docs/stable/special.html#torch.special.log_ndtr). + +## Rendering and training-time evaluation + +`plot_heatmap(..., log_input=True)` accepts natural-log values. It performs maximum +aggregation and colour normalization directly on logs, pads with `-inf`, and +formats colourbar ticks in exponent notation without creating tiny linear tick +values. `min_val_display`, if supplied, remains a nonnegative linear threshold. +Zeros (`-inf`), NaNs and infinities are masked. Ordinary `plot_heatmap` defaults +and its legacy pixel mapping remain unchanged. + +Evaluation logging during training now calls the log-domain renderer with +`log_range=(-8 * math.log(10), 0.0)` under the existing +`Class distance matrix/lvl...` figure tags. The represented quantity is still the +complementary probability, with percentage labels; near-neighbour distinctions +are no longer lost by subtracting a rounded CDF from 1. This uses float32, including +the log-probability matrix, and no matrix-wide float64 promotion. +The initial `1e-8` to `1` colour window follows the familiar legacy display range, +so extremely tiny tails do not consume most of the palette. Endpoints show +clipping with ≤/≥ labels. Distinctions below the display floor remain in the +log scores and can be explored with a different range; this is not a statistical +cutoff or a universal choice of interesting scale. + +The existing `class_similarity`, `class_distance`, and default +`plot_class_distance_matrix(model)` remain available as the known baseline. +The dendrogram still uses its existing Ward linkage and distance. Do not feed +log-tail or log-distance values directly into Ward and call that the same tree: +nonlinear distance transformations can change its merges. A future stable +clustering implementation needs its own numerical and behavioural validation. + +These diagnostics run during evaluation and do not alter training logits, loss, +optimizer updates or saved checkpoint formats. They apply to runs using this +feature branch; an already-running training process does not pick them up. + +## Evidence and exploration + +The epoch-4 example has 12,632 unit-length prototypes in 1,280 dimensions, with +zero biases. Full evaluation found 249,324 unordered off-diagonal pairs whose +legacy distance is zero, involving every class. Direct float64 log-CDF evaluation +gives a positive distance for all of those pairs, confirming numerical saturation +rather than exact equality of their vectors. Float32 log-tail is the primary +representation for the improved views; the higher-precision evaluation is only +a comparison reference. + +Tests compare log-tail values against independent `math.erfc` evaluations, reject +CDF calls in the log path, check autocast/device/dtype and multiple prototype levels, +preserve legacy heatmap pixels, and exercise padding and tails beyond linear +probability range. CPU evidence does not establish CUDA execution or GPU cost. + +For a runnable report using the real checkpoint and labelled synthetic probes, +see [the prototype explorer](../dev/prototype_space/README.md). diff --git a/mini_trainer/logging/core.py b/mini_trainer/logging/core.py index 50d55ae9..52f869ff 100644 --- a/mini_trainer/logging/core.py +++ b/mini_trainer/logging/core.py @@ -1141,7 +1141,7 @@ def figures(self, model: nn.Module | None): with main_process_first(): if get_rank() == 0 and model is not None: - cdm_figs = plot_class_distance_matrix(model) + cdm_figs = plot_class_distance_matrix(model, log_domain=True, log_range=(-8 * math.log(10), 0.0)) for lvl, fig in enumerate(cdm_figs): self.add_figure(f"Class distance matrix/lvl{lvl}", fig) try: diff --git a/mini_trainer/modeling/__init__.py b/mini_trainer/modeling/__init__.py index 388ff728..b678e7c4 100644 --- a/mini_trainer/modeling/__init__.py +++ b/mini_trainer/modeling/__init__.py @@ -11,7 +11,7 @@ predict, ) from .context import EmbeddingContext, SupervisionContext -from .distance import class_distance, class_similarity +from .distance import class_distance, class_log_similarity, class_similarity from .ema import EMATeacher, ema_lambda_per_update from .mask import mask_classifier, set_classification_mask from .prior import get_prior_method, prior_from_labels @@ -40,6 +40,7 @@ "SupervisionContext", "class_distance", "class_similarity", + "class_log_similarity", "mask_classifier", "set_classification_mask", ] diff --git a/mini_trainer/modeling/distance.py b/mini_trainer/modeling/distance.py index 45d13631..02ec7b35 100644 --- a/mini_trainer/modeling/distance.py +++ b/mini_trainer/modeling/distance.py @@ -26,6 +26,34 @@ def class_similarity(model: nn.Module, cdf: bool = False): return [_class_similarity(w, cdf=cdf) for w in W] +@torch.no_grad() +def class_log_similarity(model: nn.Module, complement: bool = False): + """Return float32 log probabilities without materializing the Normal CDF. + + Uses the existing prototype z-score. By default returns log Phi(z), with + diagonal 0. With complement=True returns log Phi(-z) = log(1 - Phi(z)), + with diagonal -inf (the exact zero self-dissimilarity). The complementary + log representation retains strongly aligned pairs even when Phi(z) rounds + to 1. No probability floor is applied. This is a representation for analysis + and plotting, not a replacement distance to pass directly into linkage. + + Like the existing diagnostics, returns one matrix per prototype level and + does not propagate gradients. Disable enclosing autocast for the diagnostic + computation; no float64 promotion is needed. + """ + weights = classification_module(model).last_layer_weights + if isinstance(weights, torch.Tensor): + weights = [weights] + results = [] + for weight in weights: + with torch.autocast(device_type=weight.device.type, enabled=False): + z = _class_similarity(weight, cdf=False) + log_probability = torch.special.log_ndtr(-z if complement else z) + log_probability.fill_diagonal_(-torch.inf if complement else 0.0) + results.append(log_probability) + return results + + @torch.no_grad() def class_distance(model: nn.Module, eps: float | None = None): return [ diff --git a/mini_trainer/visualization/plot.py b/mini_trainer/visualization/plot.py index 12f0f9fc..9ed05721 100644 --- a/mini_trainer/visualization/plot.py +++ b/mini_trainer/visualization/plot.py @@ -5,11 +5,11 @@ import torch from matplotlib import pyplot as plt from matplotlib.backends import backend_agg -from matplotlib.colors import LogNorm +from matplotlib.colors import LogNorm, Normalize from torch import nn from torchvision.transforms.functional import resize -from mini_trainer.modeling import class_similarity +from mini_trainer.modeling import class_log_similarity, class_similarity # --- Constants --- MIN_DISPLAY_DIM_HEATMAP = 500 @@ -19,7 +19,7 @@ # --- Helper: Matrix Aggregation --- -def _aggregate_matrix_max(matrix: np.ndarray, block_shape: tuple[int, int]) -> np.ndarray: +def _aggregate_matrix_max(matrix: np.ndarray, block_shape: tuple[int, int], pad_value: float = 0) -> np.ndarray: """Aggregates matrix by taking the maximum in blocks. Handles non-divisible shapes by padding. @@ -32,7 +32,7 @@ def _aggregate_matrix_max(matrix: np.ndarray, block_shape: tuple[int, int]) -> n padded_matrix = matrix if pad_rows > 0 or pad_cols > 0: - padded_matrix = np.pad(matrix, ((0, pad_rows), (0, pad_cols)), mode="constant", constant_values=0) + padded_matrix = np.pad(matrix, ((0, pad_rows), (0, pad_cols)), mode="constant", constant_values=pad_value) new_rows, new_cols = padded_matrix.shape target_rows, target_cols = new_rows // block_rows, new_cols // block_cols @@ -42,7 +42,7 @@ def _aggregate_matrix_max(matrix: np.ndarray, block_shape: tuple[int, int]) -> n # --- Helper: Matrix Scaling --- -def _get_scaled_matrix_for_display(mat: np.ndarray) -> np.ndarray: +def _get_scaled_matrix_for_display(mat: np.ndarray, pad_value: float = 0) -> np.ndarray: """Resizes matrix: downscales then upscales to fit display dimension constraints.""" processed_mat = mat orig_rows, orig_cols = mat.shape @@ -52,7 +52,7 @@ def _get_scaled_matrix_for_display(mat: np.ndarray) -> np.ndarray: block_c = math.ceil(orig_cols / MAX_DISPLAY_DIM_HEATMAP) if orig_cols > MAX_DISPLAY_DIM_HEATMAP else 1 if block_r > 1 or block_c > 1: - processed_mat = _aggregate_matrix_max(processed_mat, (block_r, block_c)) + processed_mat = _aggregate_matrix_max(processed_mat, (block_r, block_c), pad_value=pad_value) curr_rows, curr_cols = processed_mat.shape @@ -108,6 +108,54 @@ def chunks(): return rgb, norm, norm_vmin, norm_vmax +def _generate_log_heatmap_rgb_array(display_mat, min_val_display, cmap_name, percent, log_range=None): + """Map natural-log inputs directly to colours; never exponentiate a matrix.""" + if min_val_display is not None and min_val_display < 0: + raise ValueError("min_val_display must be nonnegative for log inputs") + if log_range is not None and (len(log_range) != 2 or not all(math.isfinite(x) for x in log_range) or log_range[0] >= log_range[1]): + raise ValueError("log_range must contain two finite, increasing natural-log bounds") + threshold = math.log(min_val_display) if min_val_display is not None and min_val_display > 0 else -np.inf + + def chunks(): + rows = max(1, 262144 // max(1, display_mat.shape[1])) + for start in range(0, len(display_mat), rows): + values = np.ma.masked_invalid(display_mat[start : start + rows]) + yield start, np.ma.masked_less_equal(values, threshold) + + minimum, maximum = float("inf"), -float("inf") + for _, values in chunks(): + if values.count(): + minimum = min(minimum, float(values.min())) + maximum = max(maximum, float(values.max())) + rgb = np.zeros((*display_mat.shape, 3), dtype=np.uint8) + if not np.isfinite(minimum): + return rgb, None, 0.0, 1.0 + vmin = math.floor(min(-1, minimum / math.log(10))) * math.log(10) + vmax = 0.0 if percent else maximum + if vmin >= vmax: + vmax = vmin + math.log(10) + if log_range is not None: + vmin, vmax = log_range + norm = Normalize(vmin, vmax, clip=log_range is not None) + cmap = mpl.colormaps[cmap_name].copy() + cmap.set_bad((0, 0, 0) if cmap_name == "magma" else (1, 1, 1), alpha=1) + for start, values in chunks(): + rgb[start : start + len(values)] = cmap(norm(values), bytes=True)[:, :, :3] + return rgb, norm, vmin, vmax + + +def _log_colorbar_ticks(vmin, vmax, max_ticks, percent): + """Format log probabilities without underflowing a linear tick value.""" + ticks = np.linspace(vmin, vmax, max(0, max_ticks)).tolist() + labels = [] + for tick in ticks: + exponent = tick / math.log(10) + (2 if percent else 0) + if abs(exponent - round(exponent)) < 1e-6: + exponent = round(exponent) + labels.append(f"10^{exponent:g}" + ("%" if percent else "")) + return ticks, labels + + # --- Helper: Colorbar Ticks --- def _get_colorbar_ticks_and_labels(norm_vmin: float, norm_vmax: float, max_ticks: int, percent: bool) -> tuple[list[float], list[str]]: """Generates tick values and labels for the colorbar.""" @@ -172,7 +220,7 @@ def _get_colorbar_ticks_and_labels(norm_vmin: float, norm_vmax: float, max_ticks # --- Helper: Colorbar Array Generation --- def _generate_colorbar_rgb_array( - norm_obj: mpl.colors.LogNorm, + norm_obj: mpl.colors.Normalize, cmap_name_str: str, tick_list: list[float], tick_label_list: list[str], @@ -225,10 +273,17 @@ def plot_heatmap( percent: bool = True, min_val_display: float | None = None, colorbar: bool = True, + *, + log_input: bool = False, + log_range: tuple[float, float] | None = None, ): """Plots a high-resolution confusion matrix using NumPy and Matplotlib. - Returns a combined RGB NumPy array (heatmap + colorbar), or None for empty input. + Returns a combined RGB NumPy array (heatmap + colorbar). + With log_input=True, inputs are natural logs and stay logarithmic through + aggregation, normalization and colourbar formatting. min_val_display remains + a linear threshold. -inf represents zero and is masked; padding uses -inf. + log_range optionally clips colours to natural-log bounds, not input values. """ if isinstance(mat, torch.Tensor): mat = mat.cpu().detach().float().numpy() @@ -238,10 +293,17 @@ def plot_heatmap( return img # 1. Scale matrix for display - display_mat = _get_scaled_matrix_for_display(mat) + display_mat = _get_scaled_matrix_for_display(mat, pad_value=-np.inf if log_input else 0) # 2. Generate heatmap RGB array - heatmap_rgb_array, norm_obj, vmin, vmax = _generate_heatmap_rgb_array(display_mat, min_val_display, cmap_name, percent) + if log_range is not None and not log_input: + raise ValueError("log_range requires log_input=True") + if log_input: + heatmap_rgb_array, norm_obj, vmin, vmax = _generate_log_heatmap_rgb_array( + display_mat, min_val_display, cmap_name, percent, log_range + ) + else: + heatmap_rgb_array, norm_obj, vmin, vmax = _generate_heatmap_rgb_array(display_mat, min_val_display, cmap_name, percent) if not colorbar: return heatmap_rgb_array @@ -253,7 +315,12 @@ def plot_heatmap( return np.hstack((heatmap_rgb_array, empty_cbar_space)) # 3. Get colorbar ticks and labels - tick_values, tick_labels = _get_colorbar_ticks_and_labels(vmin, vmax, max_colorbar_ticks, percent) + tick_generator = _log_colorbar_ticks if log_input else _get_colorbar_ticks_and_labels + tick_values, tick_labels = tick_generator(vmin, vmax, max_colorbar_ticks, percent) + if log_range is not None and tick_labels: + tick_labels[0] = "≤" + tick_labels[0] + if len(tick_labels) > 1: + tick_labels[-1] = "≥" + tick_labels[-1] # 4. Generate colorbar RGB array colorbar_rgb_array = _generate_colorbar_rgb_array( @@ -266,11 +333,16 @@ def plot_heatmap( return final_rgb_image -def plot_class_distance_matrix(model: nn.Module, **kwargs): +def plot_class_distance_matrix(model: nn.Module, *, log_domain: bool = False, **kwargs): """Plot the pairwise class distance matrix. CDF of pairwise inner products between rows in the last-layer weight matrix, assuming that these have unit norm. + log_domain=True computes the same complementary probability directly in + float32 log space, avoiding subtraction from a rounded CDF. The default + preserves the legacy rendering for comparisons. """ + if log_domain: + return [plot_heatmap(log_tail.cpu(), log_input=True, **kwargs) for log_tail in class_log_similarity(model, complement=True)] return [plot_heatmap(1 - csi.cpu(), **kwargs) for csi in class_similarity(model, cdf=True)] diff --git a/tests/logging/test_logging_simple.py b/tests/logging/test_logging_simple.py index c76cd037..fd875a05 100644 --- a/tests/logging/test_logging_simple.py +++ b/tests/logging/test_logging_simple.py @@ -23,7 +23,13 @@ def test_dendrogram_export_retains_text_and_closes_all_figures_on_failure(monkey monkeypatch.setattr(logger, "confusion_matrix", lambda: {}) monkeypatch.setattr(core, "main_process_first", nullcontext) monkeypatch.setattr(core, "get_rank", lambda: 0) - monkeypatch.setattr(core, "plot_class_distance_matrix", lambda model: []) + + def class_matrix(model, *, log_domain, log_range): + assert log_domain is True + assert log_range[0] < -18 and log_range[1] == 0 + return [] + + monkeypatch.setattr(core, "plot_class_distance_matrix", class_matrix) figures = [plt.figure(), plt.figure()] figures[0].text(0.5, 0.5, "Species test") monkeypatch.setattr(core, "plot_probabilistic_dendrogram", lambda model: [(fig, {}) for fig in figures]) diff --git a/tests/utils/test_log_class_similarity.py b/tests/utils/test_log_class_similarity.py new file mode 100644 index 00000000..826ebbc9 --- /dev/null +++ b/tests/utils/test_log_class_similarity.py @@ -0,0 +1,99 @@ +"""Log-domain prototype diagnostics retain representable scores, not rounded CDFs.""" + +import math +from types import SimpleNamespace + +import numpy as np +import pytest +import torch + +from mini_trainer.modeling import distance +from mini_trainer.visualization.plot import ( + _aggregate_matrix_max, + _generate_log_heatmap_rgb_array, + _log_colorbar_ticks, + plot_heatmap, +) + + +@pytest.mark.parametrize("device", ["cpu", "cuda"]) +def test_log_tail_matches_erfc_without_cdf_or_autocast_rounding(monkeypatch, device): + if device == "cuda" and not torch.cuda.is_available(): + pytest.skip("CUDA not enabled") + dim = 1280 + targets = torch.tensor([0.0, 5.0, 8.0, 12.0, 20.0, 31.0], device=device) + cosine = (targets / math.sqrt(dim - 2)).sin() + weights = torch.zeros(len(targets) + 1, dim, device=device) + weights[0, 0] = 1 + weights[1:, 0] = cosine + weights[1:, 1] = (1 - cosine.square()).sqrt() + weights.requires_grad_() + original = weights.detach().clone() + monkeypatch.setattr(distance, "classification_module", lambda model: SimpleNamespace(last_layer_weights=weights)) + z = distance.class_similarity(None, cdf=False)[0] + + def reject_cdf(*args, **kwargs): + raise AssertionError("Log-domain diagnostic must not materialize a CDF") + + monkeypatch.setattr(torch.distributions.Normal, "cdf", reject_cdf) + with torch.autocast(device_type=device, dtype=torch.bfloat16): + actual = distance.class_log_similarity(None, complement=True)[0] + log_cdf = distance.class_log_similarity(None)[0] + expected = [math.log(0.5 * math.erfc(value / math.sqrt(2))) for value in z[0, 1:].tolist()] + np.testing.assert_allclose(actual[0, 1:].cpu(), expected, rtol=2e-6, atol=1e-6) + assert actual.dtype == log_cdf.dtype == torch.float32 + assert actual.device == weights.device + assert not actual.requires_grad and weights.grad is None + assert torch.isneginf(actual.diag()).all() and (log_cdf.diag() == 0).all() + assert torch.isfinite(actual[0, 1:]).all() + assert actual[0, 1:].unique().numel() == len(targets) + torch.testing.assert_close(actual, actual.T, rtol=0, atol=0) + torch.testing.assert_close(weights.detach(), original, rtol=0, atol=0) + + +def test_log_similarity_preserves_multiple_prototype_levels(monkeypatch): + weights = [torch.eye(3), torch.eye(4)] + monkeypatch.setattr(distance, "classification_module", lambda model: SimpleNamespace(last_layer_weights=weights)) + results = distance.class_log_similarity(None, complement=True) + assert [tuple(result.shape) for result in results] == [(3, 3), (4, 4)] + for result in results: + off_diagonal = ~torch.eye(len(result), dtype=torch.bool) + torch.testing.assert_close(result[off_diagonal], torch.full_like(result[off_diagonal], -math.log(2))) + + +def test_log_heatmap_retains_extreme_tails_and_masks_zero_without_exp(monkeypatch): + values = np.array([[-np.inf, -50, -100], [-50, -500, -1000]], dtype=np.float32) + before = values.copy() + rgb, norm, _, _ = _generate_log_heatmap_rgb_array(values, None, "magma", True) + assert rgb.shape == (2, 3, 3) + assert len({tuple(rgb[r, c]) for r, c in [(0, 1), (0, 2), (1, 1), (1, 2)]}) == 4 + assert (rgb[0, 0] == 0).all() and norm is not None + np.testing.assert_array_equal(values, before) + + def reject_exp(*args, **kwargs): + raise AssertionError("Log rendering must not exponentiate matrix values") + + monkeypatch.setattr(np, "exp", reject_exp) + image = plot_heatmap(values, log_input=True) + assert image.dtype == np.uint8 and image.shape[2] == 3 + + +def test_log_padding_and_ticks_do_not_materialize_tiny_probabilities(): + values = -np.arange(1, 10, dtype=float).reshape(3, 3) + actual = _aggregate_matrix_max(values, (2, 2), pad_value=-np.inf) + np.testing.assert_array_equal(actual, [[-1, -3], [-7, -9]]) + _, labels = _log_colorbar_ticks(-1000 * math.log(10), 0, 3, True) + assert labels == ["10^-998%", "10^-498%", "10^2%"] + + +def test_colour_clipping_preserves_scores_and_ignores_extreme_outlier(): + values = np.array([[-5.0, -10.0, -15.0, -500.0]], dtype=np.float32) + before = values.copy() + image, norm, low, high = _generate_log_heatmap_rgb_array(values, None, "magma", True, log_range=(-20.0, 0.0)) + reference, _, _, _ = _generate_log_heatmap_rgb_array(values[:, :3], None, "magma", True, log_range=(-20.0, 0.0)) + np.testing.assert_array_equal(image[:, :3], reference) + np.testing.assert_array_equal(values, before) + assert (low, high) == (-20, 0) and norm.clip + assert len({tuple(colour) for colour in image[0, :3]}) == 3 + with pytest.raises(ValueError, match="increasing"): + plot_heatmap(values, log_input=True, log_range=(0.0, -10.0)) From d4663e7cdfaf919dd882335f7d4446300c081e54 Mon Sep 17 00:00:00 2001 From: asgersvenning Date: Thu, 10 Sep 2026 22:37:20 +0200 Subject: [PATCH 04/34] feat: explore prototype neighbourhoods with GBIF class images --- dev/prototype_space/README.md | 154 +++++++++++++ dev/prototype_space/check_browser.mjs | 68 ++++++ dev/prototype_space/explore.py | 262 ++++++++++++++++++++++ dev/prototype_space/photos.js | 139 ++++++++++++ dev/prototype_space/report.html | 126 +++++++++++ dev/prototype_space/serve.py | 190 ++++++++++++++++ tests/utils/test_prototype_exploration.py | 87 +++++++ tests/utils/test_prototype_photos.py | 51 +++++ 8 files changed, 1077 insertions(+) create mode 100644 dev/prototype_space/README.md create mode 100644 dev/prototype_space/check_browser.mjs create mode 100644 dev/prototype_space/explore.py create mode 100644 dev/prototype_space/photos.js create mode 100644 dev/prototype_space/report.html create mode 100644 dev/prototype_space/serve.py create mode 100644 tests/utils/test_prototype_exploration.py create mode 100644 tests/utils/test_prototype_photos.py diff --git a/dev/prototype_space/README.md b/dev/prototype_space/README.md new file mode 100644 index 00000000..6561e888 --- /dev/null +++ b/dev/prototype_space/README.md @@ -0,0 +1,154 @@ +# Prototype-space exploration + +This is an offline interactive feature prototype. The accompanying +[log-domain diagnostic API](../../docs/prototype-diagnostics.md) also supports +evaluation logging during training. The existing weight parametrization, +similarity transform and class distance remain the empirical reference. +Alternative evaluations and displays are labelled explicitly. Begin with real +checkpoint evidence; synthetic cases probe calculations and rendering, not how +the learned space ought to organize. + +Run from the feature worktree with the existing environment: + +```bash +export PYTHONPATH="$PWD" +CUDA_VISIBLE_DEVICES='' .venv/bin/python -m dev.prototype_space.explore \ + --weights /absolute/path/to/best_global-lepi-production-w32-1_epoch4.pt \ + --output tmp/prototype-report +``` + +Open `tmp/prototype-report/explorer.html` in a browser. The numerical views are self-contained. For optional GBIF image labels and a +clickable local HTTP URL: + +```bash +.venv/bin/python -m dev.prototype_space.serve \ + --directory tmp/prototype-report --port 8765 +``` + +Then open . This serves only the report +directory on loopback. Keep each concurrent task's output path and port distinct. + +Generation uses CPU float32 repository APIs for the full class-by-class matrices +and SciPy for the unchanged Ward linkage. Several GB of RAM are needed for the +12,632-class example; this is an exploratory offline tool, not a bounded-memory +replacement for training-time logging. No dependency installation is performed. +The environment needs the existing visualization dependencies (including SciPy). + +## Try the views + +1. Search a checkpoint class ID or choose the strongest/weakest nearest pair. + Follow neighbours directly from the table. The selected class updates the + profile, highlighted tree path and global matrix position. +2. Click a collapsed dendrogram tip to focus on that subtree. Use **Parent**, + **Whole tree** and **Locate selected class** to retain context. This is the + current Ward tree in a different, progressively expanded layout. +3. Compare matrix block maxima with block minima using the same leaf order and + shared colour scale. The latter retains isolated close pairs that maxima can + conceal. Both exclude self-pairs and padding. Choose float32 log-domain tails + or the legacy rounded-CDF view. Choose the familiar `1e-8` to `1` display + window, an observed 0.1–99.9 percentile window, full range or manual log10 + bounds. Clipping affects colours only; each panel reports clipped block counts + and hover retains raw values. These are not pixel-identical copies of the + original heatmap. +4. In the local matrix, switch between **Baseline class distance**, **Pre-CDF + z-score** and **−log₁₀ tail (float32)**. Hover a cell for its stored values. Local + colour ranges adapt and are disclosed; use the table for exact comparisons. +5. Switch cases to independent unit vectors, four planted groups or algebraic + edge cases. All use the real embedding width, but have much smaller class + counts. Their nearest-neighbour distributions are not matched packing controls. + +## Class image labels + +Enable **Interpret numeric class IDs as GBIF taxa and load photos** to see the +selected class and its twelve nearest prototype directions as image cards. Click +an image to navigate to that class; **Another example** cycles available images. +The prototype is the learned target direction, and the photo provides a class +label. Selecting images by their measured embedding proximity is a separate +possible extension. Exact checkpoint IDs remain unchanged, including when GBIF +reports a different accepted taxon ID. + +Only the visible neighbourhood is requested. Metadata and thumbnails are cached +in a sibling `prototype-report-gbif-cache` directory; upstream requests are +serialized. Synthetic cases never trigger lookups. Missing images or network +access leave the numerical views usable. Media credits and licenses are shown +when supplied; occurrence-data licenses are never substituted for image licenses. +The service uses the [GBIF image API](https://techdocs.gbif.org/en/openapi/images). + +## Numerical contract + +- Supported inputs are float32 `Classifier` and `HierarchicalClassifier` + checkpoints with a single linear prototype matrix. Other head families and + weight encodings are rejected. This does not replace the general model loader. +- Reconstruct the parametrized linear layer with `Classifier._normalize_layer` + and strict state loading. Keep effective magnitudes, biases and exact class + order. Do not treat weight-norm direction parameters as effective weights. +- The diagnostic adapter exposes those effective weights to the unchanged + `class_similarity(cdf=False)`, `class_similarity(cdf=True)` and `class_distance` + APIs. It does not execute the backbone, hidden layers or hierarchical inference. + Like the existing diagnostics, these describe directions, not sample occupancy. +- The global tree uses Ward linkage on the current distance. The global heatmap + quantity is the current `1 − CDF(z)`. No alternative metric replaces either. +- Neighbours are explicitly ranked by pre-CDF z, with stable class-index ordering + for exact z ties. This refines saturated distance ties rather than arbitrarily + treating zero-distance pairs as identical prototypes. +- The primary tail view uses `class_log_similarity(model, complement=True)` and + displays `−log_tail / log(10)` in float32. It neither materializes a near-one CDF + nor promotes the z-score or log-probability matrix to float64. +- The separate float64 reference computes `−torch.special.log_ndtr(z.double())` + for comparison (hover baseline distances in the neighbour table). It retains + the existing z and does not apply the baseline probability floor. Reference + neighbour distances are stored as float64; baseline and log-tail values remain + float32. Numerical values are never recovered from colours. +- Linear probabilities can still underflow at extreme z even in float64; log-tail + remains useful there. The algebraic fixture includes this case. A log-domain + view improves numerical range, not the statistical assumptions of the transform. + +The prototype checkpoint has 12,632 unit-length rows in 1,280 dimensions and zero +biases. The initial full baseline evaluation found 249,324 unordered off-diagonal +pairs with distance zero, involving every class. That is numerical evidence to +investigate, not evidence that the vectors are identical or that training failed. +All 249,324 become positive under direct float64 log-CDF evaluation; that reference +confirms the saturation independently of the primary float32 log-tail display. +Current run statistics and checkpoint SHA256 are in `summary.json`; the complete +view payload is in `report-data.json`. Generated data and weights stay out of Git. + +## Validation and next directions + +```bash +bash dev/check.sh static +bash dev/check.sh test tests/utils/test_prototype_exploration.py \ + tests/utils/test_dendrogram.py tests/utils/test_plot.py +``` + +Tests cover effective weights with non-unit magnitudes, class ordering, unchanged +checkpoint bytes, unsupported heads, self-pair/padding exclusion, saturation ties, +exact local baseline submatrices and log-domain values against an independent +`math.erfc` reference. Browser interaction checks additionally exercise case +switching, class search, neighbour navigation, tree navigation and matrix modes. + +Promising follow-ups after trying this prototype: + +- Evaluate log-domain clustering with explicit representation and compatibility + decisions. Compare any changed tree against this preserved baseline before + promoting it into training-time logging. +- Add mutual-neighbour graph exploration to expose links across tree branches. +- Add taxonomy composition at multiple neighbourhood sizes, retaining unknown + labels separately and avoiding any assumption of taxonomic agreement. +- Compare neighbour and crowding trajectories across checkpoints using class IDs; + do not interpret unconstrained embedding rotations as motion between runs. +- With held-out embeddings, add image inspection, sample alignment, margins and + observed confusion. With an explicitly chosen direction distribution, examine + decision-region occupancy. Neither is established by the prototype matrix alone. + +The code is on `feature/prototype-space`. See the [worktree guide](../worktrees.md) +and [agent coordination rules](../../.agents/rules/worktrees.md) for parallel work. + +Optional browser interaction verification with an existing Chromium installation: + +```bash +CHROMIUM_BIN=/absolute/path/to/chrome node dev/prototype_space/check_browser.mjs \ + http://localhost:8765/explorer.html tmp/prototype-browser-check +``` + +No browser dependency is installed by this helper. Screenshots and the interaction +check report stay in the ignored output directory. diff --git a/dev/prototype_space/check_browser.mjs b/dev/prototype_space/check_browser.mjs new file mode 100644 index 00000000..5e49c0cf --- /dev/null +++ b/dev/prototype_space/check_browser.mjs @@ -0,0 +1,68 @@ +// Optional browser smoke check. Set CHROMIUM_BIN; pass a report URL/path and output directory. +import {spawn} from 'node:child_process'; +import {writeFileSync,mkdtempSync,rmSync,mkdirSync,existsSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {pathToFileURL} from 'node:url'; +import {resolve,join} from 'node:path'; +const executable=process.env.CHROMIUM_BIN; +if(!executable||!existsSync(executable)||!process.argv[2])throw Error('Set CHROMIUM_BIN and pass report URL/path [output directory]'); +const output=resolve(process.argv[3]||'tmp/prototype-browser-check');mkdirSync(output,{recursive:true}); +const profile=mkdtempSync(tmpdir()+'/prototype-browser-'); +const chrome=spawn(executable,['--headless','--no-sandbox','--disable-gpu','--disable-dev-shm-usage','--no-first-run','--no-default-browser-check','--disable-background-networking','--disable-component-update','--disable-extensions','--remote-debugging-pipe','--user-data-dir='+profile],{stdio:['ignore','ignore','ignore','pipe','pipe']}); +let sequence=0,buffer=Buffer.alloc(0);const pending=new Map();const errors=[]; +chrome.stdio[4].on('data',chunk=>{buffer=Buffer.concat([buffer,chunk]);let end;while((end=buffer.indexOf(0))!==-1){const msg=JSON.parse(buffer.subarray(0,end));buffer=buffer.subarray(end+1);if(msg.id){const cb=pending.get(msg.id);if(cb){pending.delete(msg.id);msg.error?cb.reject(Error(JSON.stringify(msg.error))):cb.resolve(msg.result);}}else if(msg.method==='Runtime.exceptionThrown')errors.push(msg.params.exceptionDetails);}}); +function send(method,params={},sessionId){const id=++sequence;return new Promise((resolve,reject)=>{pending.set(id,{resolve,reject});chrome.stdio[3].write(JSON.stringify({id,method,params,...(sessionId?{sessionId}:{})})+'\0');});} +const deadline=setTimeout(()=>{console.error('Browser smoke timed out');chrome.kill('SIGTERM');process.exitCode=1;},45000); +try{ + const {targetId}=await send('Target.createTarget',{url:'about:blank'});const {sessionId}=await send('Target.attachToTarget',{targetId,flatten:true}); + const call=(m,p)=>send(m,p,sessionId);await call('Page.enable');await call('Runtime.enable');await call('Emulation.setDeviceMetricsOverride',{width:1440,height:1100,deviceScaleFactor:1,mobile:false}); + await call('Page.navigate',{url:process.argv[2].startsWith('http')?process.argv[2]:pathToFileURL(process.argv[2]).href}); + let ready=false;for(let i=0;i<100;i++){const r=await call('Runtime.evaluate',{expression:'document.querySelectorAll("#neighbours tr").length',returnByValue:true});if(r.result.value>0){ready=true;break;}await new Promise(r=>setTimeout(r,100));} + if(!ready)throw Error('Inspector did not render: '+JSON.stringify(errors)); + const result=await call('Runtime.evaluate',{expression:`(()=>{const checks=[];function check(v,s){if(!v)throw Error(s);checks.push(s)} + check(document.querySelectorAll('#stats .stat').length===4,'production summary'); + check(document.querySelectorAll('#neighbours tr').length===data.stats.k,'production neighbours'); + check(Number.isFinite(Number(document.querySelector('#neighbours tr').children[3].textContent)),'finite float32 log tail in neighbour table'); + document.getElementById('range-preset').value='full';document.getElementById('range-preset').dispatchEvent(new Event('change'));const logImage=document.getElementById('matrix-min').toDataURL();document.getElementById('global-metric').value='baseline';document.getElementById('global-metric').dispatchEvent(new Event('change'));check(document.getElementById('matrix-min').toDataURL()!==logImage,'legacy versus log-domain global matrix');document.getElementById('global-metric').value='logtail';document.getElementById('global-metric').dispatchEvent(new Event('change')); + document.getElementById('metric').value='logtail';document.getElementById('metric').dispatchEvent(new Event('change'));check(document.getElementById('local-scale').textContent.includes('−log₁₀ tail'),'stable log-tail matrix'); + const rawFirst=globalValues.logmin.find(Number.isFinite);document.getElementById('range-preset').value='focus';document.getElementById('range-preset').dispatchEvent(new Event('change'));check(document.getElementById('matrix-min').toDataURL()!==logImage,'focused versus full colour range');check(globalValues.logmin.find(Number.isFinite)===rawFirst,'display clipping preserves raw values');document.getElementById('range-preset').value='robust';document.getElementById('range-preset').dispatchEvent(new Event('change'));check(Number(document.getElementById('range-low').value)>data.matrix_log10_floor,'observed percentile window');document.getElementById('range-low').value='1';document.getElementById('range-high').value='0';document.getElementById('apply-range').click();check(document.getElementById('range-status').textContent.includes('lower < upper'),'invalid display range feedback');document.getElementById('range-preset').value='focus';document.getElementById('range-preset').dispatchEvent(new Event('change')); + + document.getElementById('crowded').click();check(document.getElementById('selected-name').textContent===data.names[data.profiles.reduce((best,p,i)=>p[0]>data.profiles[best][0]?i:best,0)],'strongest neighbour selection'); + document.getElementById('metric').value='z';document.getElementById('metric').dispatchEvent(new Event('change'));check(document.getElementById('local-scale').textContent.includes('z '),'z metric toggle'); + document.getElementById('tree-locate').click();check(document.querySelectorAll('#tree .tree-label').length<=32,'tree locate and collapse'); + document.getElementById('tree-root').click();const collapsed=[...document.querySelectorAll('#tree .tree-label')].find(n=>n.textContent.includes('expand'));if(collapsed){collapsed.dispatchEvent(new MouseEvent('click'));check(size(treeFocus)0,name);} + document.getElementById('case').value='Production checkpoint';document.getElementById('case').dispatchEvent(new Event('change')); + return checks;})()`,returnByValue:true}); + if(result.exceptionDetails)throw Error(JSON.stringify(result.exceptionDetails));if(errors.length)throw Error(JSON.stringify(errors)); + const shot=await call('Page.captureScreenshot',{format:'png'});writeFileSync(join(output,'overview.png'),Buffer.from(shot.data,'base64')); + await call('Runtime.evaluate',{expression:'document.getElementById("inspector").scrollIntoView()'}); + const local=await call('Page.captureScreenshot',{format:'png'});writeFileSync(join(output,'neighbourhood.png'),Buffer.from(local.data,'base64')); + await call('Runtime.evaluate',{expression:'document.getElementById("global-metric").closest(".panel").scrollIntoView()'});const globalShot=await call('Page.captureScreenshot',{format:'png'});writeFileSync(join(output,'matrix.png'),Buffer.from(globalShot.data,'base64'));const report={checks:result.result.value,javascript_errors:errors.length};writeFileSync(join(output,'browser-check.json'),JSON.stringify(report,null,2)+'\n');console.log(JSON.stringify(report)); + const photos=await call('Runtime.evaluate',{expression:`(async()=>{ + const checks=[];const check=(value,label)=>{if(!value)throw Error(label);checks.push(label)}; + const originalFetch=window.fetch; + const pixel='data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + window.fetch=async url=>({ok:true,json:async()=>url==='/api/health'?{photo_api:true}:{class_id:url.split('/').pop(),accepted_id:url.split('/').pop(),display_name:'Fixture class',photos:[0,1].map(i=>({image_path:pixel,creator:'Fixture photographer',license:'Fixture media license',source:'https://www.gbif.org',occurrence_id:'1'}))}}); + document.getElementById('photo-enabled').checked=true;await renderPhotos(); + check(document.querySelectorAll('.photo-card').length===data.stats.k+1,'photo neighbourhood cards'); + check(document.querySelector('.photo-card img').naturalWidth===1,'photo image loaded'); + check(document.querySelector('.photo-credit').textContent.includes('Fixture photographer'),'photo attribution'); + const next=document.querySelector('.photo-card button:not(.photo-open)');await next.onclick(); + check(document.querySelector('.photo-card .caption + .caption').textContent.includes('2 / 2'),'cycle class example'); + const expected=data.neighbours[selected][0];document.querySelectorAll('.photo-open')[1].click(); + check(selected===expected,'photo navigates to neighbour'); + document.getElementById('case').value='Algebraic edge cases';document.getElementById('case').dispatchEvent(new Event('change'));await renderPhotos(); + check(document.getElementById('photo-status').textContent.includes('No online lookup'),'synthetic case skips photo lookup'); + document.getElementById('photo-enabled').checked=false;await renderPhotos();window.fetch=originalFetch; + document.getElementById('case').value='Production checkpoint';document.getElementById('case').dispatchEvent(new Event('change')); + return checks; + })()`,awaitPromise:true,returnByValue:true}); + if(photos.exceptionDetails)throw Error(JSON.stringify(photos.exceptionDetails)); + report.checks.push(...photos.result.value);report.javascript_errors=errors.length; + writeFileSync(join(output,'browser-check.json'),JSON.stringify(report,null,2)+'\n');console.log(JSON.stringify({photo_checks:photos.result.value,javascript_errors:errors.length})); + await send('Browser.close'); +}catch(e){console.error(e);process.exitCode=1;chrome.kill('SIGTERM');}finally{clearTimeout(deadline);setTimeout(()=>rmSync(profile,{recursive:true,force:true}),1000);} diff --git a/dev/prototype_space/explore.py b/dev/prototype_space/explore.py new file mode 100644 index 00000000..c0ab1ff3 --- /dev/null +++ b/dev/prototype_space/explore.py @@ -0,0 +1,262 @@ +"""Offline prototype exploration using mini_trainer's existing diagnostic APIs.""" + +import argparse +import base64 +import hashlib +import json +import math +from pathlib import Path + +import numpy as np +import torch +from matplotlib import colormaps +from scipy.cluster.hierarchy import linkage +from scipy.spatial.distance import squareform +from torch import nn + +from mini_trainer.modeling import Classifier, class_distance, class_log_similarity, class_similarity +from mini_trainer.visualization._dendrogram_layout import linkage_layout + + +def weight_model(weight: torch.Tensor) -> nn.Module: + """A linear-only diagnostic head: values are already effective weights.""" + with torch.device("meta"): + head = Classifier(weight.shape[1], weight.shape[0], hidden=False, normalized=False) + head.linear = nn.Linear(weight.shape[1], weight.shape[0], bias=False, device="meta") + head.linear.load_state_dict({"weight": weight}, assign=True) + return nn.Sequential(head) + + +def load_prototypes(path: Path): + """Read supported floating-point linear heads without building a backbone. + + This is deliberately not a general checkpoint loader. Reject other head + families instead of treating a sublayer as the complete prototype space. + """ + state = torch.load(path, map_location="cpu", weights_only=True) + metas = [(key, value) for key, value in state.items() if key.endswith("._extra_state") and isinstance(value, dict)] + if len(metas) != 1: + raise ValueError("Expected exactly one classifier metadata record") + key, meta = metas[0] + if meta.get("classifier_class") not in { + "mini_trainer.modeling.classifier:Classifier", + "mini_trainer.hierarchical.model:HierarchicalClassifier", + }: + raise ValueError("Explorer supports only Classifier and HierarchicalClassifier linear prototypes") + prefix = key.removesuffix("_extra_state") + "linear." + linear = {key[len(prefix) :]: value for key, value in state.items() if key.startswith(prefix)} + normalized = meta.get("normalized") + weight_key = "parametrizations.weight.original1" if normalized else "weight" + raw = linear.get(weight_key) + if not isinstance(raw, torch.Tensor) or raw.ndim != 2 or raw.dtype != torch.float32: + raise ValueError("Expected float32 linear weights; quantized/other checkpoint encodings are not supported") + layer = nn.Linear(raw.shape[1], raw.shape[0], bias="bias" in linear) + if normalized: + layer = Classifier._normalize_layer(layer, orthogonal_init=False) + layer.load_state_dict(linear, strict=True) + weight = layer.weight.detach().clone() + if not torch.isfinite(weight).all() or not (weight.norm(dim=1) > 0).all(): + raise ValueError("Prototype rows must be finite and nonzero") + mapping = meta["cls2idx"] + if all(isinstance(value, dict) for value in mapping.values()): + mapping = mapping.get("0", mapping.get(0)) + if not mapping or sorted(mapping.values()) != list(range(len(weight))): + raise ValueError("Class mapping must cover every prototype exactly once") + names = [""] * len(weight) + for name, index in mapping.items(): + names[index] = str(name) + hierarchy = meta.get("labels", {}) + groups = [list(map(str, hierarchy.get(name, [name]))) for name in names] + with path.open("rb") as stream: + digest = hashlib.file_digest(stream, "sha256").hexdigest() + return ( + weight, + names, + groups, + { + "checkpoint_sha256": digest, + "checkpoint": path.name, + "classifier": meta["classifier_class"], + "normalized": normalized, + "norm_range": [weight.norm(dim=1).min().item(), weight.norm(dim=1).max().item()], + "bias_range": [layer.bias.min().item(), layer.bias.max().item()] if layer.bias is not None else None, + "analysis_script_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + "torch_version": str(torch.__version__), + "numpy_version": np.__version__, + }, + ) + + +def array_payload(array, dtype="=2 classes, >2 dimensions and >=1 neighbour") + model = weight_model(weight) + print(f"Computing repository z-scores and distances for {n:,} × {dim:,}", flush=True) + z = class_similarity(model, cdf=False)[0].numpy() + distance = class_distance(model)[0].numpy() + tail = 1 - class_similarity(model, cdf=True)[0].numpy() + log_tail = class_log_similarity(model, complement=True)[0].numpy() + assert np.isfinite(distance).all() and np.isfinite(z).all() + k = min(neighbours, n - 1) + # z is a separately labelled refinement of saturated distance ties. + # Stable sorting uses checkpoint class order for exactly equal z values. + near = np.empty((n, k), dtype=np.int32) + for i in range(n): + row = z[i].copy() + row[i] = -np.inf + near[i] = np.argsort(-row, kind="stable")[:k] + ids = np.column_stack((np.arange(n), near)) + local_d = distance[ids[:, :, None], ids[:, None, :]] + local_z = z[ids[:, :, None], ids[:, None, :]] + stable_near, _ = stable_scores(z[np.arange(n)[:, None], near]) + local_logtail = log_tail[ids[:, :, None], ids[:, None, :]] / math.log(10) + zero_count = (distance == 0).sum(axis=1) - 1 + recovered = 0 + for row in range(n): + zero_ids = np.flatnonzero(distance[row] == 0) + zero_ids = zero_ids[zero_ids > row] + stable_zero, _ = stable_scores(z[row, zero_ids]) + recovered += int((stable_zero > 0).sum()) + upper = np.triu_indices(n, 1) if n < 1024 else None + rng = np.random.default_rng(seed) + if upper is None: + i, j = rng.integers(n, size=(2, 200_000)) + i, j = i[i != j], j[i != j] + else: + i, j = upper + sampled = z[i, j] + robust_range = np.quantile(log_tail[i, j] / math.log(10), [0.001, 0.999]).tolist() + bins = np.linspace(-6, max(8, float(sampled.max()) + 0.1), 100) + hist, edges = np.histogram(sampled, bins) + stats = { + "classes": n, + "dimensions": dim, + "off_diagonal_zero_distance_pairs": int(zero_count.sum() // 2), + "classes_with_zero_distance_neighbours": int((zero_count > 0).sum()), + "zero_pairs_resolved_by_float64_logcdf": recovered, + "finite_off_diagonal_log_tail_pairs": int(np.isfinite(log_tail).sum() // 2), + "pair_sample_count": len(sampled), + "seed": seed, + "pair_sampling": "all unordered pairs" if upper is not None else "uniform ordered pairs with replacement, diagonal excluded", + "pair_z_mean": float(sampled.mean()), + "pair_z_std": float(sampled.std()), + "pair_z_quantiles": dict(zip(["1%", "50%", "99%", "max_sampled"], np.quantile(sampled, [0.01, 0.5, 0.99, 1]).tolist())), + "nearest_z_median": float(np.median(z[np.arange(n), near[:, 0]])), + "kth_z_median": float(np.median(z[np.arange(n), near[:, -1]])), + "k": k, + "histogram_excluded": int(len(sampled) - hist.sum()), + } + print(json.dumps(stats), flush=True) + print("Building unchanged Ward linkage on class_distance", flush=True) + tree = linkage(squareform(distance, checks=False), method="ward") + order, _, _, _ = linkage_layout(tree, np.ones(n, dtype=int)) + order = np.asarray(order) + low, high, block = block_extrema(tail, order) + log_low, log_high, _ = block_extrema(log_tail, order) + log_floor = math.floor(float(np.nanmin(log_low)) / math.log(10)) * math.log(10) + del distance, tail + ranks = [1, min(5, n - 1), min(12, n - 1), min(32, n - 1)] + profiles = np.empty((n, len(ranks)), dtype=np.float32) + for i in range(n): + row = z[i].copy() + row[i] = -np.inf + profiles[i] = -np.partition(-row, np.asarray(ranks) - 1)[np.asarray(ranks) - 1] + return { + "metadata": metadata or {}, + "stats": stats, + "names": names, + "groups": groups, + "neighbours": near.tolist(), + "local_distance": array_payload(local_d), + "local_z": array_payload(local_z), + "local_log10_tail": array_payload(local_logtail), + "stable_neighbour_distance": array_payload(stable_near.numpy(), dtype=" /^\d+$/.test(id); + +function photoText(tag, text, className = '') { + const node = document.createElement(tag); + node.textContent = text; + node.className = className; + return node; +} + +function photoLink(text, href) { + const node = photoText('a', text); + node.href = href; + node.target = '_blank'; + node.rel = 'noopener noreferrer'; + return node; +} + +function loadReferenceImage(image, src, signal) { + return new Promise(resolve => { + let timer; + const done = ok => { + clearTimeout(timer); + image.onload = image.onerror = null; + signal.removeEventListener('abort', abort); + resolve(ok); + }; + const abort = () => { image.removeAttribute('src'); done(false); }; + if (signal.aborted) { done(false); return; } + signal.addEventListener('abort', abort, {once: true}); + image.onload = () => done(true); + image.onerror = () => done(false); + timer = setTimeout(abort, 18000); + // Keep the image visible during loading so progressive decoding can appear. + image.src = src; + }); +} + +async function showReference(card, album, classIndex, generation, signal) { + const photos = album.photos; + card.title.textContent = album.display_name; + if (!photos.length) { card.status.textContent = 'No matching class examples in the first 20 GBIF records.'; return; } + const choice = (photoChoices.get(album.class_id) || 0) % photos.length; + const photo = photos[choice]; + card.credit.replaceChildren( + photoText('span', photo.creator + ' · ' + photo.license + ' '), + photoLink('Source', photo.source), + photoText('span', ' · '), + photoLink('GBIF record', 'https://www.gbif.org/occurrence/' + photo.occurrence_id) + ); + card.image.alt = `GBIF example of ${album.display_name}; class ${album.class_id}`; + card.status.textContent = `Loading example ${choice + 1} / ${photos.length}…`; + card.next.hidden = photos.length < 2; + card.next.disabled = true; + card.next.onclick = async () => { + if (generation !== photoGeneration) return; + photoChoices.set(album.class_id, choice + 1); + card.next.disabled = true; + await showReference(card, album, classIndex, generation, signal); + card.next.disabled = false; + }; + const loaded = await loadReferenceImage(card.image, photo.image_path, signal); + if (generation !== photoGeneration) return; + card.next.disabled = false; + card.status.textContent = loaded ? `Example ${choice + 1} / ${photos.length}${album.accepted_id !== album.class_id ? ' · GBIF accepted ID ' + album.accepted_id : ''}` : 'Image unavailable or timed out. Try another example.'; +} + +async function renderPhotos() { + const generation = ++photoGeneration; + photoController?.abort(); + photoController = new AbortController(); + const signal = photoController.signal; + const grid = $('photo-grid'); + grid.replaceChildren(); + if (!$('photo-enabled').checked) { + $('photo-status').textContent = 'Enable to browse the selected class and its nearest directions using GBIF class examples.'; + return; + } + const ids = [selected, ...data.neighbours[selected]]; + if (data.metadata.synthetic || !ids.some(i => photoNumber(data.names[i]))) { + $('photo-status').textContent = 'This case has no GBIF-ID candidates. No online lookup was made.'; + return; + } + $('photo-status').textContent = 'Loading class examples sequentially; class IDs and numerical neighbourhoods stay unchanged.'; + try { + const health = await fetch('/api/health', {signal}); + if (!health.ok || !(await health.json()).photo_api) throw Error('Photo service unavailable'); + } catch (error) { + if (signal.aborted) return; + $('photo-status').textContent = 'Photo view needs the local photo server: .venv/bin/python -m dev.prototype_space.serve --directory tmp/prototype-report. Open its localhost URL.'; + return; + } + const cards = ids.map((classIndex, rank) => { + const card = photoText('article', '', 'photo-card' + (rank === 0 ? ' photo-selected' : '')); + const open = photoText('button', '', 'photo-open'); + open.type = 'button'; + open.setAttribute('aria-label', `Explore class ${data.names[classIndex]}`); + const image = document.createElement('img'); + image.decoding = 'async'; + open.append(image); + open.onclick = () => selectClass(classIndex); + const title = photoText('strong', data.names[classIndex], 'photo-title'); + const identity = photoText('span', `${rank === 0 ? 'Selected' : 'Neighbour ' + rank} · class ${data.names[classIndex]}`, 'caption'); + const status = photoText('p', 'Waiting…', 'caption'); + const next = photoText('button', 'Another example'); + next.type = 'button'; next.hidden = true; + const credit = photoText('p', '', 'photo-credit'); + card.append(open, title, identity, status, next, credit); + grid.append(card); + return {image, title, status, next, credit}; + }); + // Only fetch the visible neighbourhood. Cache metadata in-session and images + // on the local server; do not download one image for every checkpoint class. + for (let rank = 0; rank < ids.length; rank++) { + if (signal.aborted || generation !== photoGeneration) return; + const classIndex = ids[rank], id = data.names[classIndex], card = cards[rank]; + if (!photoNumber(id)) {card.status.textContent = 'Not a GBIF ID.'; continue;} + try { + let album = photoAlbums.get(id); + if (!album) { + const response = await fetch('/api/gbif/' + id, {signal}); + album = await response.json(); + if (!response.ok) throw Error(album.error || 'GBIF lookup failed'); + photoAlbums.set(id, album); + } + if (signal.aborted || generation !== photoGeneration) return; + await showReference(card, album, classIndex, generation, signal); + } catch (error) { + if (!signal.aborted) card.status.textContent = 'Example lookup unavailable. Toggle photos to retry.'; + } + } + if (generation === photoGeneration) $('photo-status').textContent = 'Click a photo to explore that class’s neighbours. Images label classes at their learned prototype directions.'; +} +$('photo-enabled').onchange = renderPhotos; +renderPhotos(); diff --git a/dev/prototype_space/report.html b/dev/prototype_space/report.html new file mode 100644 index 00000000..bc93c9c2 --- /dev/null +++ b/dev/prototype_space/report.html @@ -0,0 +1,126 @@ + + + + +Prototype observatory + +
mini_trainer · empirical exploration

Prototype observatory

Move between the global tree, pairwise structure and a class’s local neighbourhood. The existing mini_trainer distance is the reference; the pre-CDF z-score exposes detail that can be hidden by probability rounding.

+
+ + + +
+
+

+
+
+

Distribution of pairwise z-scores

The actual transform used by mini_trainer, before the Normal CDF. Self-pairs excluded. No fitted distribution or assumed organization.

+

Neighbourhood signature

Similarity to the 1st, 5th, 12th and 32nd neighbour in the full space. High values across ranks indicate several strongly aligned directions.

Teal: selected class. Grey: per-rank median across classes. Rank points are not an embedding projection.

+
+

Browse by class image

Photo labels for the selected class and its nearest prototype directions. The prototype marks the learned target direction for the class; a GBIF class example provides its visual label. Credits and exact class IDs stay attached.

+
+

Explore the existing dendrogram

+

Same Ward linkage on class_distance, shown as a rectangular tree with at most 32 visible tips. Click a collapsed tip to expand its subtree; click a class to inspect it. Heights retain the existing linkage units.

+

+
+
+

What survives matrix aggregation?

Both panels represent the current heatmap quantity, 1 − CDF(z), in the same dendrogram leaf order. The log-domain option evaluates log Φ(−z) directly in float32. Legacy mode exposes CDF saturation; the two panels compare block summaries. Self-pairs and padding are excluded from both.

+

Clipping affects colours only, not numerical values or neighbour rankings.

+

Maximum dissimilarity per block

Uses the current renderer’s maximum-summary idea. A distant pair can dominate a block.

Closest pair per block

An explicit alternative. Reveals isolated close pairs; does not imply the entire block is close.

+

+
+
+

Local observatory ·

+

+

Hover a cell for its exact stored values; click to select its column class.

Nearest directions

Ranked by repository z-score, descending. This explicitly refines ties in baseline distance; exactly tied z-scores retain checkpoint class order.

RankClass IDBaseline dlog₁₀ tailzSame parent
+
+

Questions this opens up

With prototype weights alone

Compare local crowding across taxonomy; follow cross-branch nearest neighbours; measure how many baseline distances tie; examine neighbour stability across checkpoints. Keep alternate metrics alongside the reference rather than silently replacing it.

With held-out image embeddings

Add sample-to-prototype alignment, decision margins, observed occupancy and actual confusion. Those quantities are not inferred from this weight-only report. A sampled decision-territory experiment would be a separate geometric reference.

Provenance and limits

Class and parent IDs come from checkpoint metadata; no online taxonomic remapping. A single checkpoint does not establish a training trajectory. Synthetic cases are deliberately constructed display/calculation probes, not targets. Their smaller class counts do not provide a matched packing comparison. Zero distance may result from CDF rounding and does not prove identical vectors. Grey matrix cells contain no off-diagonal pairs; display flooring never alters stored numerical values.

+
Self-contained numerical report · optional GBIF photo lookup when enabled · computations use mini_trainer’s class_similarity and class_distance APIs.
+
+ + + + diff --git a/dev/prototype_space/serve.py b/dev/prototype_space/serve.py new file mode 100644 index 00000000..2c4eb90b --- /dev/null +++ b/dev/prototype_space/serve.py @@ -0,0 +1,190 @@ +"""Serve the local prototype report with optional, cached GBIF reference photos.""" + +import argparse +import hashlib +import json +import re +import threading +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.error import HTTPError +from urllib.parse import urlsplit +from urllib.request import urlopen + +from diskcache import Cache + +from mini_trainer.integrations import gbif + + +def web_url(value): + return isinstance(value, str) and urlsplit(value).scheme in {"http", "https"} + + +def reference_photos(class_id, taxon, occurrences, limit=6): + """Keep requested IDs, verify taxonomic membership, and retain media credits. + + These are GBIF examples, not images selected by model similarity. In + particular, an occurrence's data license is not assumed to license its photo. + """ + accepted = str(taxon.get("acceptedKey") or class_id) + photos, seen = [], set() + for occurrence in occurrences: + membership = { + str(occurrence.get(key)) + for key in ( + "taxonKey", + "acceptedTaxonKey", + "speciesKey", + "genusKey", + "familyKey", + "orderKey", + "classKey", + "phylumKey", + "kingdomKey", + ) + } + occurrence_id = str(occurrence.get("key", "")) + if not {str(class_id), accepted}.intersection(membership) or not occurrence_id.isdigit(): + continue + for media in occurrence.get("media", []): + identifier = media.get("identifier") + if media.get("type") != "StillImage" or not web_url(identifier) or identifier in seen: + continue + seen.add(identifier) + digest = hashlib.md5(identifier.encode(), usedforsecurity=False).hexdigest() + photos.append( + { + "occurrence_id": occurrence_id, + "image_path": f"/gbif-image/{occurrence_id}/{digest}", + "original": identifier, + "source": media.get("references") + if web_url(media.get("references")) + else f"https://www.gbif.org/occurrence/{occurrence_id}", + "creator": media.get("creator") or media.get("rightsHolder") or "Creator not supplied", + "license": media.get("license") or "Image license not supplied", + "scientific_name": occurrence.get("scientificName", ""), + } + ) + if len(photos) >= limit: + break + if len(photos) >= limit: + break + return { + "class_id": str(class_id), + "display_name": taxon.get("canonicalName") or taxon.get("scientificName") or str(class_id), + "accepted_id": accepted, + "taxonomic_status": taxon.get("taxonomicStatus"), + "photos": photos, + "selection": "GBIF class examples", + } + + +class PhotoService: + def __init__(self, allowed_ids, cache): + self.allowed_ids = set(allowed_ids) + self.cache = cache + self.image_paths = set() + # GBIF asks scripted image-cache users to use a single HTTP connection. + # Serialize all upstream requests, including image proxy requests. + self.lock = threading.Lock() + + def metadata(self, class_id): + if class_id not in self.allowed_ids or not class_id.isdigit(): + raise ValueError("Class is not a GBIF-ID candidate in this report") + with self.lock: + taxon = gbif.retrive_request(f"{gbif.GBIF_SPECIES_API_ENDPOINT}{class_id}") + result = gbif.retrive_request(f"https://api.gbif.org/v1/occurrence/search?taxonKey={class_id}&mediaType=StillImage&limit=20") + data = reference_photos(class_id, taxon, result.get("results", [])) + self.image_paths.update(photo["image_path"] for photo in data["photos"]) + return data + + def image(self, path): + with self.lock: + if path not in self.image_paths: + raise ValueError("Image has not been resolved for a class in this report") + cache_key = "prototype-image:" + path + cached = self.cache.get(cache_key) + if cached is not None: + return cached + occurrence, digest = path.split("/")[-2:] + url = f"https://api.gbif.org/v1/image/cache/400x/occurrence/{occurrence}/media/{digest}" + with urlopen(url, timeout=15) as response: + content_type = response.headers.get_content_type() + if content_type not in {"image/jpeg", "image/png", "image/webp", "image/gif"}: + raise ValueError("Upstream response was not a supported image") + content = response.read(8 * 1024 * 1024 + 1) + if len(content) > 8 * 1024 * 1024: + raise ValueError("Thumbnail exceeded the 8 MiB limit") + result = content, content_type + self.cache.set(cache_key, result, expire=gbif.CACHE_TIME) + return result + + +class Handler(SimpleHTTPRequestHandler): + def __init__(self, *args, service, **kwargs): + self.service = service + super().__init__(*args, **kwargs) + + def send_json(self, payload, status=200): + content = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(content))) + self.end_headers() + self.wfile.write(content) + + def do_GET(self): + path = urlsplit(self.path).path + try: + if path == "/api/health": + return self.send_json({"photo_api": True}) + if path.startswith("/api/gbif/"): + return self.send_json(self.service.metadata(path.removeprefix("/api/gbif/"))) + if re.fullmatch(r"/gbif-image/\d+/[a-f0-9]{32}", path): + content, content_type = self.service.image(path) + self.send_response(200) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(content))) + self.send_header("Cache-Control", "private, max-age=86400") + self.end_headers() + self.wfile.write(content) + return None + return super().do_GET() + except (BrokenPipeError, ConnectionResetError): + return None # Navigation can cancel a queued photo request. + except (ValueError, HTTPError, OSError, RuntimeError) as error: + try: + return self.send_json({"error": str(error)}, status=502) + except (BrokenPipeError, ConnectionResetError): + return None + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--directory", type=Path, default=Path("tmp/prototype-report")) + parser.add_argument("--port", type=int, default=8765) + args = parser.parse_args() + directory = args.directory.resolve() + data = json.loads((directory / "report-data.json").read_text()) + ids = {name for case in data.values() if not case["metadata"].get("synthetic") for name in case["names"] if name.isdigit()} + del data + # Reuse the repository request cache without writing into the user's shared + # home cache. Keep it outside the directory exposed by the report server. + cache = Cache(str(directory.with_name(directory.name + "-gbif-cache")), size_limit=256 * 1024 * 1024) + gbif._CACHE = cache + service = PhotoService(ids, cache) + handler = partial(Handler, directory=str(directory), service=service) + server = ThreadingHTTPServer(("127.0.0.1", args.port), handler) + print(f"Prototype + optional GBIF photos: http://localhost:{args.port}/explorer.html", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + cache.close() + + +if __name__ == "__main__": + main() diff --git a/tests/utils/test_prototype_exploration.py b/tests/utils/test_prototype_exploration.py new file mode 100644 index 00000000..87317b5f --- /dev/null +++ b/tests/utils/test_prototype_exploration.py @@ -0,0 +1,87 @@ +"""Offline explorer invariants: checkpoint identity, self-pairs and tied scores.""" + +import base64 +import math + +import numpy as np +import pytest +import torch +from torch import nn + +pytest.importorskip("scipy") + +from dev.prototype_space.explore import analyze, block_extrema, load_prototypes, stable_scores, synthetic_cases, weight_model +from mini_trainer.modeling import Classifier, class_distance, class_similarity + + +def test_extraction_preserves_magnitudes_bias_and_checkpoint_class_order(tmp_path): + layer = Classifier._normalize_layer(nn.Linear(4, 3), orthogonal_init=False) + with torch.no_grad(): + layer.parametrizations.weight.original0.copy_(torch.tensor([[2.0], [0.5], [3.0]])) + layer.bias.copy_(torch.tensor([0.1, -0.2, 0.3])) + state = {f"classifier.linear.{key}": value for key, value in layer.state_dict().items()} + state["classifier._extra_state"] = { + "normalized": True, + "classifier_class": "mini_trainer.hierarchical.model:HierarchicalClassifier", + "cls2idx": {"0": {"last": 2, "first": 0, "middle": 1}}, + "labels": {"first": ["first", "parent"]}, + } + path = tmp_path / "checkpoint.pt" + torch.save(state, path) + before = path.read_bytes() + weight, names, groups, meta = load_prototypes(path) + torch.testing.assert_close(weight, layer.weight, rtol=0, atol=0) + assert names == ["first", "middle", "last"] + assert groups[0] == ["first", "parent"] + assert meta["bias_range"] == [layer.bias.min().item(), layer.bias.max().item()] + assert path.read_bytes() == before + state["classifier._extra_state"]["classifier_class"] = "unsupported:MultiPrototypeHead" + torch.save(state, path) + with pytest.raises(ValueError, match="supports only"): + load_prototypes(path) + + +def test_block_summaries_exclude_diagonal_and_partial_padding(): + matrix = np.array([[0.0, 0.2, 0.8], [0.2, 0.0, 0.6], [0.8, 0.6, 0.0]]) + low, high, block = block_extrema(matrix, np.array([2, 0, 1]), size=2) + assert block == 2 + np.testing.assert_allclose(low, [[0.8, 0.2], [0.2, np.nan]]) + np.testing.assert_allclose(high, [[0.8, 0.6], [0.6, np.nan]]) + assert np.diag(matrix).tolist() == [0.0, 0.0, 0.0] + + +def test_algebraic_cases_keep_baseline_ties_and_original_submatrix_values(): + weight = synthetic_cases(1280)["Algebraic edge cases"] + model = weight_model(weight) + distance = class_distance(model)[0].numpy() + z = class_similarity(model, cdf=False)[0].numpy() + # An actual duplicate and a near duplicate both saturate the CDF, but their + # pre-CDF scores remain distinct. They must not be presented as equal vectors. + assert distance[0, 1] == distance[0, 5] == 0 + assert z[0, 1] > z[0, 5] > z[0, 3] > z[0, 2] + names = [f"class-{i}" for i in range(len(weight))] + result = analyze(weight, names, [[name] for name in names], neighbours=3) + assert result["neighbours"][0][:2] == [1, 5] + assert all(i not in row for i, row in enumerate(result["neighbours"])) + assert result["zero_count"][0] == 2 + assert result["stats"]["pair_sample_count"] == 15 + for field, original in [("local_distance", distance), ("local_z", z)]: + actual = np.frombuffer(base64.b64decode(result[field]), dtype=" 0).all() + # Beyond the float64 linear-probability range, log-tail still has meaning. + extreme_d, extreme_logtail = stable_scores(torch.tensor([56.0])) + assert extreme_d.item() == 0 and torch.isfinite(extreme_logtail).all() diff --git a/tests/utils/test_prototype_photos.py b/tests/utils/test_prototype_photos.py new file mode 100644 index 00000000..a162966f --- /dev/null +++ b/tests/utils/test_prototype_photos.py @@ -0,0 +1,51 @@ +"""Photo labels preserve class identity and media provenance.""" + +import hashlib + +import pytest + +from dev.prototype_space import serve + + +def test_reference_membership_and_media_provenance(): + image = {"type": "StillImage", "identifier": "https://example.org/moth.jpg", "creator": "Photographer"} + records = [ + {"key": 1, "taxonKey": 999, "media": [image]}, + {"key": 2, "speciesKey": 42, "license": "Occurrence data license", "media": [image, image]}, + {"key": 3, "speciesKey": 42, "media": [{**image, "type": "Sound"}]}, + {"key": 4, "speciesKey": 42, "media": [{**image, "identifier": "file:///tmp/moth.jpg"}]}, + ] + album = serve.reference_photos("17", {"acceptedKey": 42, "canonicalName": "Example moth"}, records) + assert album["class_id"] == "17" + assert album["accepted_id"] == "42" + assert album["display_name"] == "Example moth" + assert len(album["photos"]) == 1 + photo = album["photos"][0] + digest = hashlib.md5(image["identifier"].encode(), usedforsecurity=False).hexdigest() + assert photo["image_path"] == f"/gbif-image/2/{digest}" + assert photo["license"] == "Image license not supplied" + assert photo["creator"] == "Photographer" + assert photo["source"] == "https://www.gbif.org/occurrence/2" + + +def test_photo_service_restricts_requests_and_registers_images(monkeypatch): + calls = [] + + def retrieve(url): + calls.append(url) + if "/species/" in url: + return {"canonicalName": "Example"} + return {"results": [{"key": 9, "taxonKey": 42, "media": [{"type": "StillImage", "identifier": "https://example.org/a.jpg"}]}]} + + monkeypatch.setattr(serve.gbif, "retrive_request", retrieve) + service = serve.PhotoService({"42"}, {}) + with pytest.raises(ValueError): + service.metadata("43") + with pytest.raises(ValueError): + service.image("/gbif-image/9/unresolved") + assert calls == [] + album = service.metadata("42") + path = album["photos"][0]["image_path"] + service.cache["prototype-image:" + path] = (b"cached image", "image/jpeg") + assert service.image(path) == (b"cached image", "image/jpeg") + assert len(calls) == 2 From 015009fbc34366793674b678b1b603ea2d8e30ff Mon Sep 17 00:00:00 2001 From: asgersvenning Date: Thu, 10 Sep 2026 22:47:29 +0200 Subject: [PATCH 05/34] agent: record prototype explorer validation and handoff --- .../notes/2026-09-10-prototype-explorer.md | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .agents/notes/2026-09-10-prototype-explorer.md diff --git a/.agents/notes/2026-09-10-prototype-explorer.md b/.agents/notes/2026-09-10-prototype-explorer.md new file mode 100644 index 00000000..10977cf5 --- /dev/null +++ b/.agents/notes/2026-09-10-prototype-explorer.md @@ -0,0 +1,63 @@ +# Prototype explorer handoff + +Status: completed +Updated: 2026-09-10 +Scope: initial prototype-space exploration, log-domain diagnostics, GBIF image labels +Related: `42cef5c`, `d4663e7`; [reproduction guide](../../dev/prototype_space/README.md) + +## Context and decision + +The feature is isolated on `feature/prototype-space` in +`/home/asger/mini_trainer/.worktrees/prototype-space`. The main `quant` checkout +received only the worktree workflow/rules; its pre-existing roadmap edits were +verified unchanged. No subagents were started. Shared `.venv` was used read-only. + +The real epoch-4 checkpoint supplies the baseline geometry. Existing effective +weight handling, cosine-to-z transform and Ward distances are retained. Added +float32 log-tail computation and adjustable colour clipping distinguish numerical +saturation from actual prototype identity. Public GBIF examples visually label +classes at their learned target directions; measured image embedding proximity +would be a separate selection criterion. Taxon names never replace checkpoint IDs. + +## Evidence and limits + +- Final source revision: `d4663e7` (includes numerical commit `42cef5c`). +- Full `bash dev/check.sh all`: 655 passed, 162 skipped, 1 expected failure; + exit 0 in 598.56 seconds. Ruff, formatting and both import contracts passed. + Used absolute worktree `PYTHONPATH`, `MPLCONFIGDIR=/tmp/mini-trainer-prototype-mpl`, + `OMP_NUM_THREADS=4 MKL_NUM_THREADS=4 OPENBLAS_NUM_THREADS=4`, and + `PYTEST_ADDOPTS='-o faulthandler_timeout=120'`. CUDA was hidden. This does not + establish GPU execution or performance. No package installation was performed. +- A preceding sandboxed full run was interrupted after waiting on loader workers: + 340 passed, 13 skipped. The successful rerun allowed local multiprocessing + sockets. Do not confuse that interrupted result with full-suite success. +- Browser helper: 25 interaction checks, zero JavaScript errors, including + colour controls, baseline comparisons, tree/search/neighbour navigation, + synthetic cases, photo cycling and photo navigation. Photo controls used + deterministic fixtures; a separate live check rendered actual GBIF images for + class 1837646 and its neighbours with credits. +- Real payload: 12,632 x 1,280 prototypes; 249,324 unordered baseline zero-distance + pairs, all positive under the independent float64 log-CDF reference; all + 79,777,396 off-diagonal float32 log-tail pairs finite. Three labelled synthetic + probes accompany the checkpoint; their smaller class counts are not matched + packing controls. +- Checkpoint SHA256: + `42d50f56cb0c6e3ee17ae64fbbcc4eb2ae24335ffe7080197c479c130fff5a81`. +- Ignored, machine-local artifacts: `tmp/prototype-report/`, + `tmp/prototype-browser-check/`, `tmp/prototype-live-browser-check/`. + The generated HTML was checked against current templates and stored numerical + JSON. The JSON records the numerical-generation script hash; later UI-only + refreshes did not recompute or relabel numerical provenance. +- Preview was healthy at `http://localhost:8765/explorer.html` on completion, + served with `python -m dev.prototype_space.serve`; it is not a persistent service. + GBIF metadata/thumbnails are cached outside the served directory. Images require + optional network access; the numerical report is self-contained. +- Evaluation logging changes apply to this feature branch, not to an already + running training process. Existing Ward clustering remains the baseline; + alternate stable clustering and measured sample occupancy are future work. + +## Next actions + +None required for this initial exploration. Gather feedback on the runnable views +before choosing further directions listed in the reproduction guide. Keep source +integration into `quant` separate from this completed feature-branch handoff. From 02de749189c434c9f8f8a72fd9a58246113283f9 Mon Sep 17 00:00:00 2001 From: asgersvenning Date: Thu, 10 Sep 2026 22:54:10 +0200 Subject: [PATCH 06/34] feat: add linked spatial prototype projections with fidelity measures --- dev/prototype_space/README.md | 29 ++++++++++ dev/prototype_space/check_browser.mjs | 19 +++++++ dev/prototype_space/explore.py | 7 ++- dev/prototype_space/projection.js | 69 ++++++++++++++++++++++++ dev/prototype_space/projection.py | 64 ++++++++++++++++++++++ dev/prototype_space/report.html | 7 ++- tests/utils/test_prototype_projection.py | 43 +++++++++++++++ 7 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 dev/prototype_space/projection.js create mode 100644 dev/prototype_space/projection.py create mode 100644 tests/utils/test_prototype_projection.py diff --git a/dev/prototype_space/README.md b/dev/prototype_space/README.md index 6561e888..fa0d6cd4 100644 --- a/dev/prototype_space/README.md +++ b/dev/prototype_space/README.md @@ -57,6 +57,35 @@ The environment needs the existing visualization dependencies (including SciPy). edge cases. All use the real embedding width, but have much smaller class counts. Their nearest-neighbour distributions are not matched packing controls. +## Spatial projection + +The map projects all prototype directions into a common 2D coordinate system. +Choose **PCA 1–2** or **PCA 3–4**, scroll to zoom, drag to pan, and click a point +to update the existing inspector and image browser. **Fit selected neighbourhood** +zooms to the selected class and its original-space neighbours without refitting +the projection. Selection preserves the viewing transform; **Fit all** resets it. + +The calculation normalizes effective rows using the same directional geometry as +the cosine diagnostic, centers them, and computes PCA through float32 covariance +eigendecomposition. No whitening or per-feature standardization is applied. +Axes use equal spatial scale. Eigenvector signs are fixed for reproducibility; +degenerate eigenspaces can still rotate between numerical implementations. See +the [PCA reference](https://scikit-learn.org/stable/modules/generated/sklearn.decomposition.PCA.html). + +Each plane reports its retained variance and the overlap between its Euclidean +top-k neighbours and the original z-ranked top-k neighbours, averaged across all +classes and for the selected class. Projected ties use checkpoint row order. Teal +points and links identify original-space neighbours; orange rings identify extra +2D neighbours. A low overlap is evidence that this view loses local geometry. +Neither projected proximity nor a visible gap replaces the original scores. +Each synthetic case is projected independently; axes are not aligned across cases. + +For the epoch-4 checkpoint, PC 1–2 retains 0.6784% of directional variance and +0.6551% of original top-12 neighbours on average; PC 3–4 retains 0.6234% and +0.7026%, respectively. These measured values show that global two-axis PCA is +very lossy for these weights. It provides a linear reference for future +neighbourhood-preserving projections. + ## Class image labels Enable **Interpret numeric class IDs as GBIF taxa and load photos** to see the diff --git a/dev/prototype_space/check_browser.mjs b/dev/prototype_space/check_browser.mjs index 5e49c0cf..2cda240f 100644 --- a/dev/prototype_space/check_browser.mjs +++ b/dev/prototype_space/check_browser.mjs @@ -21,6 +21,22 @@ try{ if(!ready)throw Error('Inspector did not render: '+JSON.stringify(errors)); const result=await call('Runtime.evaluate',{expression:`(()=>{const checks=[];function check(v,s){if(!v)throw Error(s);checks.push(s)} check(document.querySelectorAll('#stats .stat').length===4,'production summary'); + check(projectionHits.length===data.names.length,'projection contains every class'); + check(document.getElementById('projection-quality').textContent.includes('Variance retained'),'projection reports distortion'); + const projectionBefore=document.getElementById('projection-map').toDataURL(); + document.getElementById('projection-plane').selectedIndex=1;document.getElementById('projection-plane').dispatchEvent(new Event('change')); + check(document.getElementById('projection-map').toDataURL()!==projectionBefore,'alternate PCA plane'); + document.getElementById('projection-plane').selectedIndex=0;document.getElementById('projection-plane').dispatchEvent(new Event('change')); + const allScale=projectionView.scale;document.getElementById('projection-focus').click();check(projectionView.scale>=allScale,'fit selected projected neighbourhood'); + document.getElementById('projection-reset').click(); + const canvas=document.getElementById('projection-map'),bounds=canvas.getBoundingClientRect(); + const oldScale=projectionView.scale;canvas.dispatchEvent(new WheelEvent('wheel',{clientX:bounds.left+bounds.width/2,clientY:bounds.top+bounds.height/2,deltaY:-150,cancelable:true}));check(projectionView.scale>oldScale,'projection zoom'); + const oldX=projectionView.x;projectionDrag={point:projectionMouse({clientX:bounds.left+100,clientY:bounds.top+100}),view:{...projectionView},moved:false};canvas.onpointermove({clientX:bounds.left+150,clientY:bounds.top+100});canvas.onpointerup({pointerId:1});check(projectionView.x!==oldX,'projection pan'); + document.getElementById('projection-reset').click(); + const target=projectionHits.findIndex(([x,y],i)=>i!==selected&&x>20&&x<1180&&y>20&&y<580&&projectionHit([x,y])===i);check(target>=0,'projected class is selectable'); + const [px,py]=projectionHits[target];const event={button:0,pointerId:1,clientX:bounds.left+px*bounds.width/1200,clientY:bounds.top+py*bounds.height/600};projectionDrag={point:projectionMouse(event),view:{...projectionView},moved:false};canvas.onpointerup(event); + check(selected===target&&document.getElementById('selected-name').textContent===data.names[target],'projection selection updates inspector'); + check(document.querySelectorAll('#neighbours tr').length===data.stats.k,'production neighbours'); check(Number.isFinite(Number(document.querySelector('#neighbours tr').children[3].textContent)),'finite float32 log tail in neighbour table'); document.getElementById('range-preset').value='full';document.getElementById('range-preset').dispatchEvent(new Event('change'));const logImage=document.getElementById('matrix-min').toDataURL();document.getElementById('global-metric').value='baseline';document.getElementById('global-metric').dispatchEvent(new Event('change'));check(document.getElementById('matrix-min').toDataURL()!==logImage,'legacy versus log-domain global matrix');document.getElementById('global-metric').value='logtail';document.getElementById('global-metric').dispatchEvent(new Event('change')); @@ -38,6 +54,9 @@ try{ document.getElementById('case').value='Production checkpoint';document.getElementById('case').dispatchEvent(new Event('change')); return checks;})()`,returnByValue:true}); if(result.exceptionDetails)throw Error(JSON.stringify(result.exceptionDetails));if(errors.length)throw Error(JSON.stringify(errors)); + await call('Runtime.evaluate',{expression:'document.getElementById("projection-panel").scrollIntoView()'}); + const projectionShot=await call('Page.captureScreenshot',{format:'png'});writeFileSync(join(output,'projection.png'),Buffer.from(projectionShot.data,'base64')); + await call('Runtime.evaluate',{expression:'window.scrollTo(0,0)'}); const shot=await call('Page.captureScreenshot',{format:'png'});writeFileSync(join(output,'overview.png'),Buffer.from(shot.data,'base64')); await call('Runtime.evaluate',{expression:'document.getElementById("inspector").scrollIntoView()'}); const local=await call('Page.captureScreenshot',{format:'png'});writeFileSync(join(output,'neighbourhood.png'),Buffer.from(local.data,'base64')); diff --git a/dev/prototype_space/explore.py b/dev/prototype_space/explore.py index c0ab1ff3..ab00b169 100644 --- a/dev/prototype_space/explore.py +++ b/dev/prototype_space/explore.py @@ -17,6 +17,8 @@ from mini_trainer.modeling import Classifier, class_distance, class_log_similarity, class_similarity from mini_trainer.visualization._dendrogram_layout import linkage_layout +from .projection import prototype_projections + def weight_model(weight: torch.Tensor) -> nn.Module: """A linear-only diagnostic head: values are already effective weights.""" @@ -195,6 +197,7 @@ def analyze(weight, names, groups, *, neighbours=12, seed=42, metadata=None): row[i] = -np.inf profiles[i] = -np.partition(-row, np.asarray(ranks) - 1)[np.asarray(ranks) - 1] return { + "projections": prototype_projections(weight, near.tolist()), "metadata": metadata or {}, "stats": stats, "names": names, @@ -251,7 +254,9 @@ def main(): (args.output / "report-data.json").write_text(payload) template = Path(__file__).with_name("report.html").read_text() (args.output / "explorer.html").write_text( - template.replace("__REPORT_DATA__", payload).replace("__PHOTO_SCRIPT__", Path(__file__).with_name("photos.js").read_text()) + template.replace("__REPORT_DATA__", payload) + .replace("__PHOTO_SCRIPT__", Path(__file__).with_name("photos.js").read_text()) + .replace("__PROJECTION_SCRIPT__", Path(__file__).with_name("projection.js").read_text()) ) summary = {name: {"metadata": case["metadata"], "stats": case["stats"]} for name, case in cases.items()} (args.output / "summary.json").write_text(json.dumps(summary, indent=2) + "\n") diff --git a/dev/prototype_space/projection.js b/dev/prototype_space/projection.js new file mode 100644 index 00000000..fbdf7688 --- /dev/null +++ b/dev/prototype_space/projection.js @@ -0,0 +1,69 @@ +/* Coordinates are fitted offline. This canvas changes only the viewing transform. */ +let projectionCase = null; +let projectionView = {x:0, y:0, scale:1}; +let projectionDrag = null; +let projectionHits = []; +const projectionCanvas = $('projection-map'); +function currentProjection() { return data.projections?.[$('projection-plane').value]; } +function projectionPoint(point) {return [600+(point[0]-projectionView.x)*projectionView.scale,300-(point[1]-projectionView.y)*projectionView.scale];} +function fitProjection(ids) { + const p=currentProjection();if(!p)return; + const points=ids.map(i=>p.coordinates[i]); + let x0=Infinity,x1=-Infinity,y0=Infinity,y1=-Infinity; + for(const [x,y] of points){x0=Math.min(x0,x);x1=Math.max(x1,x);y0=Math.min(y0,y);y1=Math.max(y1,y);} + projectionView={x:(x0+x1)/2,y:(y0+y1)/2,scale:Math.min(1050/Math.max(.0001,x1-x0),450/Math.max(.0001,y1-y0))}; +} +function drawProjection() { + const ctx=projectionCanvas.getContext('2d');ctx.clearRect(0,0,1200,600); + if(projectionCase!==data){ + projectionCase=data;$('projection-plane').replaceChildren(); + for(const name of Object.keys(data.projections||{})){const option=document.createElement('option');option.value=option.textContent=name;$('projection-plane').append(option);} + fitProjection(data.names.map((_,i)=>i)); + } + const p=currentProjection();if(!p){$('projection-quality').textContent='Regenerate this report to include spatial projections.';return;} + projectionHits=p.coordinates.map(projectionPoint); + const original=new Set(data.neighbours[selected]),projected=new Set(p.neighbours[selected]); + ctx.lineWidth=1;ctx.strokeStyle='#dce3e9';ctx.fillStyle='#52687b';ctx.font='12px system-ui'; + // Both axes use the same units per pixel, including after pan/zoom. + for(let i=0;i<=4;i++){ + const x=60+i*270,y=30+i*135; + ctx.beginPath();ctx.moveTo(x,20);ctx.lineTo(x,570);ctx.stroke(); + ctx.fillText((projectionView.x+(x-600)/projectionView.scale).toPrecision(3),x+3,590); + ctx.beginPath();ctx.moveTo(35,y);ctx.lineTo(1180,y);ctx.stroke(); + ctx.fillText((projectionView.y-(y-300)/projectionView.scale).toPrecision(3),2,y+13); + } + ctx.fillText(p.axes[0]+' →',1120,15);ctx.fillText(p.axes[1],5,15); + for(let i=0;i1200||y<0||y>600)continue; + ctx.fillStyle='#7c90a34d';ctx.beginPath();ctx.arc(x,y,2,0,2*Math.PI);ctx.fill(); + } + const [sx,sy]=projectionHits[selected]; + if($('projection-edges').checked){ctx.strokeStyle='#087e8b88';for(const i of original){const [x,y]=projectionHits[i];ctx.beginPath();ctx.moveTo(sx,sy);ctx.lineTo(x,y);ctx.stroke();}} + for(const i of projected){if(original.has(i))continue;const [x,y]=projectionHits[i];ctx.strokeStyle='#c87719';ctx.lineWidth=1.8;ctx.beginPath();ctx.arc(x,y,5,0,2*Math.PI);ctx.stroke();} + ctx.font='12px system-ui'; + for(const i of original){const [x,y]=projectionHits[i];ctx.fillStyle='#087e8b';ctx.beginPath();ctx.arc(x,y,4.5,0,2*Math.PI);ctx.fill();ctx.fillText(data.names[i],x+7,y-5);} + ctx.fillStyle='#c2344b';ctx.beginPath();ctx.arc(sx,sy,6,0,2*Math.PI);ctx.fill();ctx.font='bold 13px system-ui';ctx.fillText(data.names[selected],sx+9,sy+17); + const fractions=p.variance_fraction.map(v=>(v*100).toFixed(2)); + $('projection-quality').textContent=`Variance retained: ${(100*p.variance_fraction.reduce((a,b)=>a+b,0)).toFixed(2)}% (${p.axes[0]} ${fractions[0]}%, ${p.axes[1]} ${fractions[1]}%). Original top-${data.stats.k} neighbours retained among plane top-${data.stats.k}: ${(100*p.mean_retained_fraction).toFixed(1)}% averaged over classes; ${(100*p.retained_fraction[selected]).toFixed(1)}% for selected class. Exact plane-distance ties use class order.`; +} +function projectionMouse(event){const r=projectionCanvas.getBoundingClientRect();return [(event.clientX-r.left)*1200/r.width,(event.clientY-r.top)*600/r.height];} +function projectionHit(point){let best=-1,distance=100;projectionHits.forEach(([x,y],i)=>{const d=(x-point[0])**2+(y-point[1])**2;if(d{if(e.button!==0)return;projectionDrag={point:projectionMouse(e),view:{...projectionView},moved:false};projectionCanvas.setPointerCapture(e.pointerId);}; +projectionCanvas.onpointermove=e=>{ + const point=projectionMouse(e); + if(projectionDrag){const dx=point[0]-projectionDrag.point[0],dy=point[1]-projectionDrag.point[1];if(Math.hypot(dx,dy)>3)projectionDrag.moved=true;projectionView.x=projectionDrag.view.x-dx/projectionView.scale;projectionView.y=projectionDrag.view.y+dy/projectionView.scale;drawProjection();return;} + const id=projectionHit(point); + $('projection-hover').textContent=id<0?'Hover a point for its class ID.':`Class ${data.names[id]} · checkpoint row ${id}${id===selected?' · selected':data.neighbours[selected].includes(id)?' · original-space neighbour':currentProjection().neighbours[selected].includes(id)?' · nearby in this plane only':''}`; +}; +projectionCanvas.onpointerup=e=>{if(!projectionDrag)return;const click=!projectionDrag.moved;projectionDrag=null;if(projectionCanvas.hasPointerCapture(e.pointerId))projectionCanvas.releasePointerCapture(e.pointerId);if(click){const id=projectionHit(projectionMouse(e));if(id>=0)selectClass(id);}}; +projectionCanvas.onpointercancel=()=>{projectionDrag=null;}; +projectionCanvas.addEventListener('wheel',e=>{ + e.preventDefault();const [x,y]=projectionMouse(e),old=projectionView.scale; + const scale=Math.max(1,Math.min(1e7,old*Math.exp(-Math.max(-300,Math.min(300,e.deltaY))*.002))); + projectionView.x+=(x-600)*(1/old-1/scale);projectionView.y-=(y-300)*(1/old-1/scale);projectionView.scale=scale;drawProjection(); +},{passive:false}); +$('projection-plane').onchange=()=>{fitProjection(data.names.map((_,i)=>i));drawProjection();}; +$('projection-reset').onclick=()=>{fitProjection(data.names.map((_,i)=>i));drawProjection();}; +$('projection-focus').onclick=()=>{fitProjection([selected,...data.neighbours[selected]]);drawProjection();}; +$('projection-edges').onchange=drawProjection; +drawProjection(); diff --git a/dev/prototype_space/projection.py b/dev/prototype_space/projection.py new file mode 100644 index 00000000..61753b3c --- /dev/null +++ b/dev/prototype_space/projection.py @@ -0,0 +1,64 @@ +"""Linear spatial views with measured neighbourhood preservation.""" + +import numpy as np +import torch +from scipy.spatial import cKDTree + + +def projected_neighbours(coordinates, k): + """Euclidean neighbours in the displayed plane; exact ties use class order.""" + coordinates = np.asarray(coordinates, dtype=np.float64) + tree = cKDTree(coordinates) + distances, _ = tree.query(coordinates, k=k + 1) + result = [] + for i, radius in enumerate(distances[:, -1]): + candidates = np.asarray(tree.query_ball_point(coordinates[i], np.nextafter(radius, np.inf)), dtype=int) + candidates = candidates[candidates != i] + squared = ((coordinates[candidates] - coordinates[i]) ** 2).sum(axis=1) + result.append(candidates[np.lexsort((candidates, squared))[:k]].tolist()) + return result + + +@torch.no_grad() +def prototype_projections(weight, neighbours): + """Project the same row directions used in the cosine diagnostic. + + PCA centers unit rows without whitening or feature standardization. It does + not change prototype weights, repository distances or their neighbour ranks. + Both displayed axes share one spatial scale. Fit each case independently. + """ + weight = weight.detach().cpu().float() + norms = weight.norm(dim=1, keepdim=True) + if not torch.isfinite(weight).all() or (norms == 0).any(): + raise ValueError("Projection requires finite, nonzero prototype rows") + directions = weight / norms + centered = directions - directions.mean(dim=0) + # Symmetric covariance eigendecomposition avoids an N x N pairwise matrix. + gram = centered.T @ centered + eigenvalues, eigenvectors = torch.linalg.eigh(gram) + total = centered.square().sum().item() + count = min(4, weight.shape[1]) + axes = eigenvectors[:, -count:].flip(1) + # Fix the otherwise arbitrary sign; degenerate eigenspaces may still rotate. + pivots = axes.abs().argmax(dim=0) + axes *= torch.where(axes[pivots, torch.arange(count)] < 0, -1.0, 1.0) + coordinates = (centered @ axes).numpy() + variances = eigenvalues[-count:].flip(0).clamp_min(0).numpy() + result = {} + original = [set(row) for row in neighbours] + k = len(neighbours[0]) + for start in range(0, count - 1, 2): + points = coordinates[:, start : start + 2] + near = projected_neighbours(points, k) + retained = [len(original[i].intersection(row)) / k for i, row in enumerate(near)] + key = f"PCA {start + 1}–{start + 2}" + result[key] = { + "coordinates": points.tolist(), + "axes": [f"PC {start + 1}", f"PC {start + 2}"], + "variance_fraction": (variances[start : start + 2] / total).tolist() if total > 0 else [0.0, 0.0], + "neighbours": near, + "retained_fraction": retained, + "mean_retained_fraction": float(np.mean(retained)), + "method": "Centered PCA of unit prototype directions; float32 covariance eigendecomposition; no whitening", + } + return result diff --git a/dev/prototype_space/report.html b/dev/prototype_space/report.html index bc93c9c2..79549047 100644 --- a/dev/prototype_space/report.html +++ b/dev/prototype_space/report.html @@ -30,6 +30,10 @@

Distribution of pairwise z-scores

The actual transform used by mini_trainer, before the Normal CDF. Self-pairs excluded. No fitted distribution or assumed organization.

Neighbourhood signature

Similarity to the 1st, 5th, 12th and 32nd neighbour in the full space. High values across ranks indicate several strongly aligned directions.

Teal: selected class. Grey: per-rank median across classes. Rank points are not an embedding projection.

+

Prototype space · spatial projection

+

Centered PCA of unit prototype directions, with equal scale on both axes. Scroll to zoom, drag to pan, click a point to inspect its class. Each case is fitted separately.

+ +

Hover a point for its class ID.

Red: selected class · teal: its neighbours in the original space · orange rings: nearby in this plane only · grey: other classes. Links follow the original-space ranking. Projected gaps and apparent overlaps can differ from the full embedding geometry.

Browse by class image

Photo labels for the selected class and its nearest prototype directions. The prototype marks the learned target direction for the class; a GBIF class example provides its visual label. Credits and exact class IDs stay attached.

Explore the existing dendrogram

@@ -105,7 +109,7 @@ $('global-ticks').replaceChildren();for(let i=0;i<=4;i++){const span=document.createElement('span');span.textContent=(i===0?'≤ ':i===4?'≥ ':'')+`10^${(lo+(hi-lo)*i/4).toFixed(1)}`;$('global-ticks').append(span);} $('matrix-note').textContent=`Each pixel summarizes up to ${data.matrix_block} × ${data.matrix_block} classes. Cyan lines locate the selected class’s block. Both panels share the selected colour window; full numerical values remain available. ${useLog?'Float32 log-domain values.':'Legacy CDF rounding is retained for comparison.'}`; } -function selectClass(id){selected=id;$('search').value=data.names[id];$('selected-name').textContent=data.names[id];$('selected-meta').textContent=`Checkpoint row ${id} · taxonomy IDs: ${data.groups[id].join(' → ')}`;$('ties').textContent=`${data.zero_count[id]} other classes have baseline distance 0 to this class. Neighbour ranks below use the pre-CDF z-score; the baseline distances are unchanged. The log-tail view evaluates log Φ(−z) directly in float32, retaining the repository z-score. Hover a baseline distance in the table for a separate float64 reference.`;drawProfile();drawTree();drawLocal();if(typeof renderPhotos==='function')renderPhotos();const pos=(positions[id]+.5)/(Math.ceil(data.names.length/data.matrix_block)*data.matrix_block)*100;document.querySelectorAll('.marker:not(.horizontal)').forEach(m=>m.style.left=pos+'%');document.querySelectorAll('.marker.horizontal').forEach(m=>m.style.top=pos+'%');} +function selectClass(id){selected=id;$('search').value=data.names[id];$('selected-name').textContent=data.names[id];$('selected-meta').textContent=`Checkpoint row ${id} · taxonomy IDs: ${data.groups[id].join(' → ')}`;$('ties').textContent=`${data.zero_count[id]} other classes have baseline distance 0 to this class. Neighbour ranks below use the pre-CDF z-score; the baseline distances are unchanged. The log-tail view evaluates log Φ(−z) directly in float32, retaining the repository z-score. Hover a baseline distance in the table for a separate float64 reference.`;drawProfile();drawTree();drawLocal();if(typeof renderPhotos==='function')renderPhotos();if(typeof drawProjection==='function')drawProjection();const pos=(positions[id]+.5)/(Math.ceil(data.names.length/data.matrix_block)*data.matrix_block)*100;document.querySelectorAll('.marker:not(.horizontal)').forEach(m=>m.style.left=pos+'%');document.querySelectorAll('.marker.horizontal').forEach(m=>m.style.top=pos+'%');} function drawLocal(){ const svg=$('local');svg.replaceChildren();const ids=[selected,...data.neighbours[selected]],n=ids.length,stride=n*n,offset=selected*stride,mode=$('metric').value,useZ=mode!=='distance',arr=mode==='z'?zs:mode==='logtail'?Float32Array.from(logtails,v=>-v):distances,vals=Array.from(arr.slice(offset,offset+stride));const finite=vals.filter((_,i)=>Math.floor(i/n)!==i%n),lo=useZ?Math.min(...finite):0,hi=Math.max(...finite,lo+1e-9),cell=400/n; const palette=t=>`hsl(${220-190*t} 65% ${94-55*t}%)`; @@ -123,4 +127,5 @@ Object.keys(cases).forEach(name=>{const o=document.createElement('option');o.value=o.textContent=name;$('case').append(o);});$('case').onchange=setCase;setCase(); + diff --git a/tests/utils/test_prototype_projection.py b/tests/utils/test_prototype_projection.py new file mode 100644 index 00000000..cc9f2731 --- /dev/null +++ b/tests/utils/test_prototype_projection.py @@ -0,0 +1,43 @@ +"""Projection geometry and distortion indicators have independent references.""" + +import numpy as np +import torch + +from dev.prototype_space.projection import projected_neighbours, prototype_projections + + +def test_pca_matches_svd_preserves_input_and_reports_actual_neighbours(): + weight = torch.randn(24, 7, generator=torch.Generator().manual_seed(123)) + weight *= torch.linspace(0.2, 3.0, len(weight))[:, None] + before = weight.clone() + unit = (weight / weight.norm(dim=1, keepdim=True)).numpy() + similarity = unit @ unit.T + np.fill_diagonal(similarity, -np.inf) + neighbours = np.argsort(-similarity, axis=1, kind="stable")[:, :4].tolist() + result = prototype_projections(weight, neighbours) + centered = unit - unit.mean(axis=0) + _, singular, vt = np.linalg.svd(centered, full_matrices=False) + for start, plane in zip([0, 2], result.values()): + actual = np.array(plane["coordinates"]) + expected = centered @ vt[start : start + 2].T + # Signs are arbitrary; Euclidean geometry must match independent SVD. + np.testing.assert_allclose(actual @ actual.T, expected @ expected.T, atol=3e-6, rtol=1e-5) + np.testing.assert_allclose(plane["variance_fraction"], singular[start : start + 2] ** 2 / (singular**2).sum(), atol=1e-6) + distances = ((actual[:, None] - actual[None, :]) ** 2).sum(axis=2) + np.fill_diagonal(distances, np.inf) + near = np.argsort(distances, axis=1, kind="stable")[:, :4].tolist() + assert plane["neighbours"] == near + retained = [len(set(a).intersection(b)) / 4 for a, b in zip(near, neighbours)] + assert plane["retained_fraction"] == retained + assert plane["mean_retained_fraction"] == np.mean(retained) + torch.testing.assert_close(weight, before, rtol=0, atol=0) + + +def test_degenerate_projection_and_distance_ties_are_explicit_and_finite(): + points = np.zeros((5, 2)) + assert projected_neighbours(points, 2) == [[1, 2], [0, 2], [0, 1], [0, 1], [0, 1]] + result = prototype_projections(torch.ones(2, 4), [[1], [0]]) + for plane in result.values(): + assert plane["variance_fraction"] == [0.0, 0.0] + assert np.isfinite(plane["coordinates"]).all() + assert plane["retained_fraction"] == [1.0, 1.0] From 8cc7bf245c86a00d9a8e26fe53de661232b983c2 Mon Sep 17 00:00:00 2001 From: asgersvenning Date: Thu, 10 Sep 2026 23:14:42 +0200 Subject: [PATCH 07/34] feat: add angular prototype maps with culled GBIF thumbnails --- dev/prototype_space/README.md | 46 ++++++++++++- dev/prototype_space/check_browser.mjs | 28 +++++++- dev/prototype_space/explore.py | 18 +++-- dev/prototype_space/photos.js | 1 + dev/prototype_space/projection.js | 14 ++-- dev/prototype_space/projection.py | 48 +++++++++++++ dev/prototype_space/report.html | 8 ++- dev/prototype_space/thumbnails.js | 86 ++++++++++++++++++++++++ tests/utils/test_prototype_projection.py | 22 ++++++ 9 files changed, 254 insertions(+), 17 deletions(-) create mode 100644 dev/prototype_space/thumbnails.js diff --git a/dev/prototype_space/README.md b/dev/prototype_space/README.md index fa0d6cd4..f62aadb9 100644 --- a/dev/prototype_space/README.md +++ b/dev/prototype_space/README.md @@ -14,7 +14,7 @@ Run from the feature worktree with the existing environment: export PYTHONPATH="$PWD" CUDA_VISIBLE_DEVICES='' .venv/bin/python -m dev.prototype_space.explore \ --weights /absolute/path/to/best_global-lepi-production-w32-1_epoch4.pt \ - --output tmp/prototype-report + --output tmp/prototype-report --angular-tsne ``` Open `tmp/prototype-report/explorer.html` in a browser. The numerical views are self-contained. For optional GBIF image labels and a @@ -60,12 +60,12 @@ The environment needs the existing visualization dependencies (including SciPy). ## Spatial projection The map projects all prototype directions into a common 2D coordinate system. -Choose **PCA 1–2** or **PCA 3–4**, scroll to zoom, drag to pan, and click a point +Choose **Angular t-SNE**, **PCA 1–2** or **PCA 3–4**, scroll to zoom, drag to pan, and click a point to update the existing inspector and image browser. **Fit selected neighbourhood** zooms to the selected class and its original-space neighbours without refitting the projection. Selection preserves the viewing transform; **Fit all** resets it. -The calculation normalizes effective rows using the same directional geometry as +The PCA calculation normalizes effective rows using the same directional geometry as the cosine diagnostic, centers them, and computes PCA through float32 covariance eigendecomposition. No whitening or per-feature standardization is applied. Axes use equal spatial scale. Eigenvector signs are fixed for reproducibility; @@ -86,6 +86,46 @@ For the epoch-4 checkpoint, PC 1–2 retains 0.6784% of directional variance and very lossy for these weights. It provides a linear reference for future neighbourhood-preserving projections. +### Angular map + +`--angular-tsne` also fits a nonlinear map using angular distances in radians, +recovered from the unchanged repository transform as `pi/2 - z/sqrt(D-2)`. +This retains its cosine clamping and avoids the saturated CDF. t-SNE uses those +precomputed angular distances, random initialization with seed 42, perplexity +`min(30, (classes-1)/3)`, automatic learning rate, 1,000 iterations and Barnes-Hut +angle 0.5. It does not use PCA preprocessing. The angular map is selected first +when present; omit the flag for the faster PCA-only report. + +On the real checkpoint it retains 48.62% of original top-12 neighbours, compared +with 0.66% for PC1–2. This is an observed result for these settings and weights, +not evidence that map areas, gaps between groups or relative group sizes measure +spherical geometry. Variance explained is not defined for this map. Its +parameters, KL divergence and neighbour overlap are stored with the coordinates. +See the [t-SNE reference](https://scikit-learn.org/stable/modules/generated/sklearn.manifold.TSNE.html). + +### Thumbnails on the map + +Enable **GBIF thumbnails on map** independently of the image-card panel. Configure +image size (32–160 CSS pixels), density (5–200 images per megapixel of map viewport) +and allowable pairwise rectangle overlap (0–75%). The density gives a maximum +budget, capped at 200 images; viewport and overlap culling can produce fewer. +The overlap fraction uses the full thumbnail rectangle including its class label. + +Images stay centered on actual projected points. Culling prioritizes the selected +class and its original-space neighbours, then uses a stable mixed class order. +Only fully visible, admitted rectangles trigger image requests. Pan/zoom cancels +stale loads, hides stale placements immediately, and recomputes after a short +pause; no images are fetched while dragging continuously. Cached metadata is +shared with the class cards, and changing a class example also refreshes map +thumbnails. Decoded image references are bounded to 128 entries; server caching +remains shared. Missing photos leave the underlying point visible. Retry clears +failed lookups. Synthetic cases make no photo requests. + +Click a thumbnail to inspect the class. Hover or keyboard focus exposes the +scientific name, exact ID, creator, image license and source links below the map. +Culling never changes scores, coordinates or neighbour ranks. The controls affect +only which image labels are displayed. + ## Class image labels Enable **Interpret numeric class IDs as GBIF taxa and load photos** to see the diff --git a/dev/prototype_space/check_browser.mjs b/dev/prototype_space/check_browser.mjs index 2cda240f..abcfc927 100644 --- a/dev/prototype_space/check_browser.mjs +++ b/dev/prototype_space/check_browser.mjs @@ -22,7 +22,7 @@ try{ const result=await call('Runtime.evaluate',{expression:`(()=>{const checks=[];function check(v,s){if(!v)throw Error(s);checks.push(s)} check(document.querySelectorAll('#stats .stat').length===4,'production summary'); check(projectionHits.length===data.names.length,'projection contains every class'); - check(document.getElementById('projection-quality').textContent.includes('Variance retained'),'projection reports distortion'); + check(document.getElementById('projection-quality').textContent.includes('neighbours retained'),'projection reports distortion'); const projectionBefore=document.getElementById('projection-map').toDataURL(); document.getElementById('projection-plane').selectedIndex=1;document.getElementById('projection-plane').dispatchEvent(new Event('change')); check(document.getElementById('projection-map').toDataURL()!==projectionBefore,'alternate PCA plane'); @@ -76,7 +76,31 @@ try{ check(selected===expected,'photo navigates to neighbour'); document.getElementById('case').value='Algebraic edge cases';document.getElementById('case').dispatchEvent(new Event('change'));await renderPhotos(); check(document.getElementById('photo-status').textContent.includes('No online lookup'),'synthetic case skips photo lookup'); - document.getElementById('photo-enabled').checked=false;await renderPhotos();window.fetch=originalFetch; + document.getElementById('photo-enabled').checked=false;await renderPhotos(); + document.getElementById('case').value='Production checkpoint';document.getElementById('case').dispatchEvent(new Event('change')); + const fixturePoints=[[60,60],[100,60],[260,100],[450,50]], config={width:400,height:200,size:64,density:200,overlap:0}; + check(thumbnailLayout(fixturePoints,[0,1,2,3],config).boxes.map(b=>b.id).join(',')==='0,2','thumbnail viewport and overlap culling'); + check(thumbnailLayout(fixturePoints,[0,1,2,3],{...config,overlap:.5}).boxes.length===3,'thumbnail allowable overlap'); + check(thumbnailLayout(fixturePoints,[0,1,2,3],{...config,density:5}).boxes.length===1,'thumbnail density budget'); + check(thumbnailLayout(fixturePoints,[0,1,2,3],{...config,size:140}).boxes.length===1,'thumbnail size changes culling'); + document.getElementById('map-photos').checked=true;scheduleMapThumbnails();clearTimeout(mapThumbTimer);await renderMapThumbnails(); + check(document.querySelectorAll('.map-thumb').length>0,'map thumbnails load'); + check([...document.querySelectorAll('.map-thumb img')].every(img=>img.naturalWidth>0),'map thumbnails decode'); + const first=mapThumbLayout[0];check(mapThumbnailHit([first.x*1200/projectionCanvas.getBoundingClientRect().width,first.y*600/projectionCanvas.getBoundingClientRect().height])===first.id,'thumbnail hit testing'); + showMapPhotoCredit(first.id);check(document.getElementById('map-photo-credit').textContent.includes('Fixture photographer'),'map thumbnail attribution'); + document.querySelector('.map-thumb').click();check(selected===first.id,'thumbnail selection updates class'); + document.getElementById('map-photos').checked=false;scheduleMapThumbnails();check(document.querySelectorAll('.map-thumb').length===0,'map thumbnail toggle clears images'); + + const fixtureFetch=window.fetch;let release; + window.fetch=()=>new Promise(resolve=>{release=()=>resolve({ok:true,json:async()=>({photo_api:true})})}); + document.getElementById('map-photos').checked=true;scheduleMapThumbnails();clearTimeout(mapThumbTimer);const pendingMap=renderMapThumbnails(); + document.getElementById('map-photos').checked=false;scheduleMapThumbnails();release();await pendingMap; + check(document.querySelectorAll('.map-thumb').length===0,'cancelled thumbnail load stays hidden'); + let syntheticRequests=0;window.fetch=async()=>{syntheticRequests++;throw Error('Unexpected synthetic lookup')}; + document.getElementById('case').value='Algebraic edge cases';document.getElementById('case').dispatchEvent(new Event('change'));document.getElementById('map-photos').checked=true;scheduleMapThumbnails(); + check(syntheticRequests===0&&document.getElementById('map-photo-status').textContent.includes('Synthetic'),'synthetic map skips photo requests'); + document.getElementById('map-photos').checked=false;scheduleMapThumbnails();window.fetch=fixtureFetch; + window.fetch=originalFetch; document.getElementById('case').value='Production checkpoint';document.getElementById('case').dispatchEvent(new Event('change')); return checks; })()`,awaitPromise:true,returnByValue:true}); diff --git a/dev/prototype_space/explore.py b/dev/prototype_space/explore.py index ab00b169..27f52b62 100644 --- a/dev/prototype_space/explore.py +++ b/dev/prototype_space/explore.py @@ -17,7 +17,7 @@ from mini_trainer.modeling import Classifier, class_distance, class_log_similarity, class_similarity from mini_trainer.visualization._dendrogram_layout import linkage_layout -from .projection import prototype_projections +from .projection import angular_tsne, prototype_projections def weight_model(weight: torch.Tensor) -> nn.Module: @@ -121,7 +121,7 @@ def block_extrema(matrix, order, size=640): return lows, highs, block -def analyze(weight, names, groups, *, neighbours=12, seed=42, metadata=None): +def analyze(weight, names, groups, *, neighbours=12, seed=42, metadata=None, include_tsne=False): n, dim = weight.shape if n < 2 or dim <= 2 or neighbours < 1: raise ValueError("Need >=2 classes, >2 dimensions and >=1 neighbour") @@ -196,8 +196,12 @@ def analyze(weight, names, groups, *, neighbours=12, seed=42, metadata=None): row = z[i].copy() row[i] = -np.inf profiles[i] = -np.partition(-row, np.asarray(ranks) - 1)[np.asarray(ranks) - 1] + projections = prototype_projections(weight, near.tolist()) + if include_tsne: + print("Fitting angular t-SNE", flush=True) + projections["Angular t-SNE"] = angular_tsne(z, dim, near.tolist(), seed) return { - "projections": prototype_projections(weight, near.tolist()), + "projections": projections, "metadata": metadata or {}, "stats": stats, "names": names, @@ -242,13 +246,16 @@ def main(): parser.add_argument("--weights", type=Path, required=True) parser.add_argument("--output", type=Path, required=True) parser.add_argument("--threads", type=int, default=4) + parser.add_argument("--angular-tsne", action="store_true", help="Also fit an angular-distance t-SNE map") args = parser.parse_args() torch.set_num_threads(args.threads) weight, names, groups, provenance = load_prototypes(args.weights) - cases = {"Production checkpoint": analyze(weight, names, groups, metadata=provenance)} + cases = {"Production checkpoint": analyze(weight, names, groups, metadata=provenance, include_tsne=args.angular_tsne)} for name, sample in synthetic_cases(weight.shape[1]).items(): labels = [f"synthetic-{i}" for i in range(len(sample))] - cases[name] = analyze(sample, labels, [[label] for label in labels], metadata={"seed": 42, "synthetic": True}) + cases[name] = analyze( + sample, labels, [[label] for label in labels], metadata={"seed": 42, "synthetic": True}, include_tsne=args.angular_tsne + ) args.output.mkdir(parents=True, exist_ok=True) payload = json.dumps(cases, separators=(",", ":"), allow_nan=False).replace("<", "\\u003c") (args.output / "report-data.json").write_text(payload) @@ -257,6 +264,7 @@ def main(): template.replace("__REPORT_DATA__", payload) .replace("__PHOTO_SCRIPT__", Path(__file__).with_name("photos.js").read_text()) .replace("__PROJECTION_SCRIPT__", Path(__file__).with_name("projection.js").read_text()) + .replace("__THUMBNAIL_SCRIPT__", Path(__file__).with_name("thumbnails.js").read_text()) ) summary = {name: {"metadata": case["metadata"], "stats": case["stats"]} for name, case in cases.items()} (args.output / "summary.json").write_text(json.dumps(summary, indent=2) + "\n") diff --git a/dev/prototype_space/photos.js b/dev/prototype_space/photos.js index 88051c54..4b484c46 100644 --- a/dev/prototype_space/photos.js +++ b/dev/prototype_space/photos.js @@ -61,6 +61,7 @@ async function showReference(card, album, classIndex, generation, signal) { photoChoices.set(album.class_id, choice + 1); card.next.disabled = true; await showReference(card, album, classIndex, generation, signal); + if(typeof scheduleMapThumbnails==='function')scheduleMapThumbnails(); card.next.disabled = false; }; const loaded = await loadReferenceImage(card.image, photo.image_path, signal); diff --git a/dev/prototype_space/projection.js b/dev/prototype_space/projection.js index fbdf7688..d4b1f383 100644 --- a/dev/prototype_space/projection.js +++ b/dev/prototype_space/projection.js @@ -18,6 +18,7 @@ function drawProjection() { if(projectionCase!==data){ projectionCase=data;$('projection-plane').replaceChildren(); for(const name of Object.keys(data.projections||{})){const option=document.createElement('option');option.value=option.textContent=name;$('projection-plane').append(option);} + if(data.projections?.['Angular t-SNE'])$('projection-plane').value='Angular t-SNE'; fitProjection(data.names.map((_,i)=>i)); } const p=currentProjection();if(!p){$('projection-quality').textContent='Regenerate this report to include spatial projections.';return;} @@ -41,18 +42,21 @@ function drawProjection() { if($('projection-edges').checked){ctx.strokeStyle='#087e8b88';for(const i of original){const [x,y]=projectionHits[i];ctx.beginPath();ctx.moveTo(sx,sy);ctx.lineTo(x,y);ctx.stroke();}} for(const i of projected){if(original.has(i))continue;const [x,y]=projectionHits[i];ctx.strokeStyle='#c87719';ctx.lineWidth=1.8;ctx.beginPath();ctx.arc(x,y,5,0,2*Math.PI);ctx.stroke();} ctx.font='12px system-ui'; - for(const i of original){const [x,y]=projectionHits[i];ctx.fillStyle='#087e8b';ctx.beginPath();ctx.arc(x,y,4.5,0,2*Math.PI);ctx.fill();ctx.fillText(data.names[i],x+7,y-5);} - ctx.fillStyle='#c2344b';ctx.beginPath();ctx.arc(sx,sy,6,0,2*Math.PI);ctx.fill();ctx.font='bold 13px system-ui';ctx.fillText(data.names[selected],sx+9,sy+17); - const fractions=p.variance_fraction.map(v=>(v*100).toFixed(2)); - $('projection-quality').textContent=`Variance retained: ${(100*p.variance_fraction.reduce((a,b)=>a+b,0)).toFixed(2)}% (${p.axes[0]} ${fractions[0]}%, ${p.axes[1]} ${fractions[1]}%). Original top-${data.stats.k} neighbours retained among plane top-${data.stats.k}: ${(100*p.mean_retained_fraction).toFixed(1)}% averaged over classes; ${(100*p.retained_fraction[selected]).toFixed(1)}% for selected class. Exact plane-distance ties use class order.`; + for(const i of original){const [x,y]=projectionHits[i];ctx.fillStyle='#087e8b';ctx.beginPath();ctx.arc(x,y,4.5,0,2*Math.PI);ctx.fill();if(!$('map-photos').checked)ctx.fillText(data.names[i],x+7,y-5);} + ctx.fillStyle='#c2344b';ctx.beginPath();ctx.arc(sx,sy,6,0,2*Math.PI);ctx.fill();ctx.font='bold 13px system-ui';if(!$('map-photos').checked)ctx.fillText(data.names[selected],sx+9,sy+17); + const fractions=p.variance_fraction?.map(v=>(v*100).toFixed(2)); + const summary=fractions?`Variance retained: ${(100*p.variance_fraction.reduce((a,b)=>a+b,0)).toFixed(2)}% (${p.axes[0]} ${fractions[0]}%, ${p.axes[1]} ${fractions[1]}%). `:`Angular t-SNE · perplexity ${p.parameters.perplexity.toFixed(1)} · seed ${p.parameters.seed}. Map gaps and areas do not measure spherical distances or areas. `; + $('projection-quality').textContent=`${summary}Original top-${data.stats.k} neighbours retained among plane top-${data.stats.k}: ${(100*p.mean_retained_fraction).toFixed(1)}% averaged over classes; ${(100*p.retained_fraction[selected]).toFixed(1)}% for selected class. Exact plane-distance ties use class order.`; + if(typeof scheduleMapThumbnails==='function')scheduleMapThumbnails(); } function projectionMouse(event){const r=projectionCanvas.getBoundingClientRect();return [(event.clientX-r.left)*1200/r.width,(event.clientY-r.top)*600/r.height];} -function projectionHit(point){let best=-1,distance=100;projectionHits.forEach(([x,y],i)=>{const d=(x-point[0])**2+(y-point[1])**2;if(d=0)return hit;}let best=-1,distance=100;projectionHits.forEach(([x,y],i)=>{const d=(x-point[0])**2+(y-point[1])**2;if(d{if(e.button!==0)return;projectionDrag={point:projectionMouse(e),view:{...projectionView},moved:false};projectionCanvas.setPointerCapture(e.pointerId);}; projectionCanvas.onpointermove=e=>{ const point=projectionMouse(e); if(projectionDrag){const dx=point[0]-projectionDrag.point[0],dy=point[1]-projectionDrag.point[1];if(Math.hypot(dx,dy)>3)projectionDrag.moved=true;projectionView.x=projectionDrag.view.x-dx/projectionView.scale;projectionView.y=projectionDrag.view.y+dy/projectionView.scale;drawProjection();return;} const id=projectionHit(point); + if(typeof showMapPhotoCredit==='function')showMapPhotoCredit(id); $('projection-hover').textContent=id<0?'Hover a point for its class ID.':`Class ${data.names[id]} · checkpoint row ${id}${id===selected?' · selected':data.neighbours[selected].includes(id)?' · original-space neighbour':currentProjection().neighbours[selected].includes(id)?' · nearby in this plane only':''}`; }; projectionCanvas.onpointerup=e=>{if(!projectionDrag)return;const click=!projectionDrag.moved;projectionDrag=null;if(projectionCanvas.hasPointerCapture(e.pointerId))projectionCanvas.releasePointerCapture(e.pointerId);if(click){const id=projectionHit(projectionMouse(e));if(id>=0)selectClass(id);}}; diff --git a/dev/prototype_space/projection.py b/dev/prototype_space/projection.py index 61753b3c..4ee0a4ab 100644 --- a/dev/prototype_space/projection.py +++ b/dev/prototype_space/projection.py @@ -62,3 +62,51 @@ def prototype_projections(weight, neighbours): "method": "Centered PCA of unit prototype directions; float32 covariance eigendecomposition; no whitening", } return result + + +def angular_distances(z, dimensions): + """Invert the repository's clamped cosine-to-z transform to radians.""" + angles = np.maximum(0, np.pi / 2 - np.asarray(z, dtype=np.float32) / np.sqrt(np.float32(dimensions - 2))) + np.fill_diagonal(angles, 0) + return angles + + +def angular_tsne(z, dimensions, neighbours, seed=42, *, iterations=1000): + """An explicit nonlinear display of angular neighbourhoods, not a new metric.""" + from sklearn.manifold import TSNE + + perplexity = min(30.0, (len(z) - 1) / 3) + estimator = TSNE( + metric="precomputed", + init="random", + perplexity=perplexity, + random_state=seed, + learning_rate="auto", + max_iter=iterations, + method="barnes_hut", + angle=0.5, + n_jobs=4, + ) + points = estimator.fit_transform(angular_distances(z, dimensions)) + near = projected_neighbours(points, len(neighbours[0])) + retained = [len(set(a).intersection(b)) / len(a) for a, b in zip(neighbours, near)] + return { + "coordinates": points.tolist(), + "axes": ["t-SNE 1", "t-SNE 2"], + "variance_fraction": None, + "neighbours": near, + "retained_fraction": retained, + "mean_retained_fraction": float(np.mean(retained)), + "method": ( + "t-SNE of angular distances recovered from repository z; map areas and inter-cluster gaps are not spherical areas or distances" + ), + "parameters": { + "perplexity": perplexity, + "seed": seed, + "iterations": iterations, + "init": "random", + "metric": "precomputed angular radians", + "angle": 0.5, + }, + "kl_divergence": float(estimator.kl_divergence_), + } diff --git a/dev/prototype_space/report.html b/dev/prototype_space/report.html index 79549047..84d79c26 100644 --- a/dev/prototype_space/report.html +++ b/dev/prototype_space/report.html @@ -16,6 +16,7 @@ table{border-collapse:collapse;width:100%;font-size:12px}th,td{text-align:left;border-bottom:1px solid var(--line);padding:7px 5px}th{color:var(--muted)}td button{border:0;background:transparent;color:#087781;padding:0;font-size:12px;text-decoration:underline}.mono{font-family:ui-monospace,monospace}.notice{border-left:3px solid #c28936;padding:10px 14px;background:#fff8ec;font-size:13px}details{margin-top:14px}summary{cursor:pointer;font-weight:600}pre{font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere}.panel p{margin-top:0}#search-status{font-size:12px;color:var(--muted)}#cell-info{min-height:44px}.selected-title{font-size:24px}.case-note{margin-bottom:18px}.tree-hit{cursor:pointer}footer{font-size:13px;color:var(--muted);padding-top:10px} @media(max-width:850px){.grid{grid-template-columns:1fr}.stats{grid-template-columns:1fr 1fr}.controls{position:static}main{padding:16px 12px}.panel{padding:17px}header{padding:24px}.controls input{width:160px}} .photo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:14px}.photo-card{border:1px solid var(--line);border-radius:8px;padding:10px;min-width:0}.photo-selected{border:2px solid var(--accent)}.photo-open{width:100%;padding:0;border:0;background:#edf2f5;height:150px;overflow:hidden}.photo-open img{display:block;width:100%;height:100%;object-fit:contain}.photo-title{display:block;margin-top:8px}.photo-credit{font-size:10px;color:var(--muted);overflow-wrap:anywhere;margin:8px 0 0!important}.photo-credit a{color:var(--accent)}.photo-card button:not(.photo-open){font-size:11px;padding:4px 6px} +.map-thumb{position:absolute;transform:translate(-50%,-50%);padding:2px;border:2px solid #087e8b;border-radius:4px;background:white;pointer-events:none;overflow:hidden;box-shadow:0 1px 4px #0003}.map-thumb img{display:block;width:100%;object-fit:contain;background:#f5f7fa}.map-thumb span{display:block;font-size:10px;height:16px;line-height:16px;white-space:nowrap;overflow:hidden}.map-thumb:focus-visible{outline:3px solid #ffae3d}
mini_trainer · empirical exploration

Prototype observatory

Move between the global tree, pairwise structure and a class’s local neighbourhood. The existing mini_trainer distance is the reference; the pre-CDF z-score exposes detail that can be hidden by probability rounding.

@@ -31,8 +32,10 @@

Neighbourhood signature

Similarity to the 1st, 5th, 12th and 32nd neighbour in the full space. High values across ranks indicate several strongly aligned directions.

Teal: selected class. Grey: per-rank median across classes. Rank points are not an embedding projection.

Prototype space · spatial projection

-

Centered PCA of unit prototype directions, with equal scale on both axes. Scroll to zoom, drag to pan, click a point to inspect its class. Each case is fitted separately.

- +

Compare linear PCA with angular t-SNE when available. Scroll to zoom, drag to pan, click a point or thumbnail to inspect its class. Each case is fitted separately.

+
+
+

Hover a point for its class ID.

Red: selected class · teal: its neighbours in the original space · orange rings: nearby in this plane only · grey: other classes. Links follow the original-space ranking. Projected gaps and apparent overlaps can differ from the full embedding geometry.

Browse by class image

Photo labels for the selected class and its nearest prototype directions. The prototype marks the learned target direction for the class; a GBIF class example provides its visual label. Credits and exact class IDs stay attached.

@@ -128,4 +131,5 @@ + diff --git a/dev/prototype_space/thumbnails.js b/dev/prototype_space/thumbnails.js new file mode 100644 index 00000000..f2489961 --- /dev/null +++ b/dev/prototype_space/thumbnails.js @@ -0,0 +1,86 @@ +/* Image rectangles stay anchored to projected coordinates; culling never moves points. */ +let mapThumbTimer = null, mapThumbController = null, mapThumbGeneration = 0; +let mapThumbLayout = []; +const mapThumbImages = new Map(); +const mapThumbFailures = new Set(); +function thumbnailLayout(points, priority, {width,height,size,density,overlap}) { + const budget=Math.min(200,Math.max(1,Math.floor(width*height*density/1e6))); + const chosen=[],used=new Set();const boxWidth=size+8,boxHeight=size+24; + for(const id of priority){ + if(used.has(id))continue;used.add(id);const [x,y]=points[id]; + const box={id,x,y,left:x-boxWidth/2,top:y-boxHeight/2,width:boxWidth,height:boxHeight}; + if(box.left<0||box.top<0||box.left+boxWidth>width||box.top+boxHeight>height)continue; + const area=boxWidth*boxHeight; + const blocked=chosen.some(other=>{ + const shared=Math.max(0,Math.min(box.left+box.width,other.left+other.width)-Math.max(box.left,other.left))*Math.max(0,Math.min(box.top+box.height,other.top+other.height)-Math.max(box.top,other.top)); + return shared/area>overlap+1e-12; + }); + if(!blocked)chosen.push(box);if(chosen.length>=budget)break; + } + return {boxes:chosen,budget}; +} +function mapThumbnailHit(point){ + const rect=projectionCanvas.getBoundingClientRect(),x=point[0]*rect.width/1200,y=point[1]*rect.height/600; + const box=mapThumbLayout.find(b=>x>=b.left&&x<=b.left+b.width&&y>=b.top&&y<=b.top+b.height); + return box?.id??-1; +} +function showMapPhotoCredit(index){ + const node=$('map-photo-credit');node.replaceChildren();if(index<0)return; + const id=data.names[index],album=photoAlbums.get(id);if(!album?.photos.length)return; + const photo=album.photos[(photoChoices.get(id)||0)%album.photos.length]; + node.append(photoText('span',`${album.display_name} · class ${id} · ${photo.creator} · ${photo.license} · `),photoLink('Image source',photo.source),photoText('span',' · '),photoLink('GBIF record','https://www.gbif.org/occurrence/'+photo.occurrence_id)); +} +function scheduleMapThumbnails(){ + clearTimeout(mapThumbTimer);mapThumbController?.abort();++mapThumbGeneration; + mapThumbLayout=[];$('map-thumbnail-layer').replaceChildren(); + const size=Number($('map-photo-size').value),density=Number($('map-photo-density').value),overlap=Number($('map-photo-overlap').value); + $('map-size-value').value=size+' px';$('map-density-value').value=density+' / MP';$('map-overlap-value').value=overlap+'%'; + if(!$('map-photos').checked){$('map-photo-status').textContent='Enable map thumbnails to interpret numeric class IDs as GBIF taxa. Images stay at their projected positions.';return;} + if(data.metadata.synthetic){$('map-photo-status').textContent='Synthetic case: no GBIF photo requests.';return;} + $('map-photo-status').textContent='Updating visible thumbnails…'; + mapThumbTimer=setTimeout(renderMapThumbnails,180); +} +async function renderMapThumbnails(){ + const generation=mapThumbGeneration,caseData=data; + mapThumbController=new AbortController();const signal=mapThumbController.signal; + const rect=projectionCanvas.getBoundingClientRect(),size=Number($('map-photo-size').value); + const config={width:rect.width,height:rect.height,size,density:Number($('map-photo-density').value),overlap:Number($('map-photo-overlap').value)/100}; + const points=projectionHits.map(([x,y])=>[x*rect.width/1200,y*rect.height/600]); + // Selected class first, then its true neighbours; other classes have stable + // mixed row order so dense regions do not win simply by checkpoint ordering. + const remaining=data.names.map((_,i)=>i).sort((a,b)=>(Math.imul(a,2654435761)>>>0)-(Math.imul(b,2654435761)>>>0)); + const priority=[selected,...data.neighbours[selected],...remaining].filter(i=>photoNumber(data.names[i])&&!mapThumbFailures.has(data.names[i])); + const {boxes,budget}=thumbnailLayout(points,priority,config); + let loaded=0,unavailable=0; + const status=()=>{$('map-photo-status').textContent=`${loaded} images loaded · ${boxes.length} visible slots · budget ${budget} for this viewport. Maximum pairwise rectangle overlap ${Math.round(config.overlap*100)}%. ${unavailable} unavailable. Hover for credits; click to inspect. Colours/positions and neighbour ranks are unchanged.`;}; + if(!boxes.length){status();return;} + try{const health=await fetch('/api/health',{signal});if(!health.ok||!(await health.json()).photo_api)throw Error('Photo server unavailable');} + catch(error){if(!signal.aborted)$('map-photo-status').textContent='Map photos require the local GBIF photo server. Numerical views remain available.';return;} + for(const box of boxes){ + if(signal.aborted||generation!==mapThumbGeneration||data!==caseData)return; + const id=data.names[box.id]; + try{ + let album=photoAlbums.get(id); + if(!album){const response=await fetch('/api/gbif/'+id,{signal});album=await response.json();if(!response.ok)throw Error(album.error||'Photo lookup failed');photoAlbums.set(id,album);} + if(signal.aborted||generation!==mapThumbGeneration)return; + if(!album.photos.length){mapThumbFailures.add(id);unavailable++;status();continue;} + const choice=(photoChoices.get(id)||0)%album.photos.length,photo=album.photos[choice]; + let image=mapThumbImages.get(photo.image_path)?.cloneNode(); + const button=photoText('button','','map-thumb');button.type='button';button.style.left=box.x+'px';button.style.top=box.y+'px';button.style.width=box.width+'px';button.style.height=box.height+'px';button.style.zIndex=String(boxes.length-boxes.indexOf(box));button.style.borderColor=box.id===selected?'#c2344b':data.neighbours[selected].includes(box.id)?'#087e8b':'#7c90a3';button.setAttribute('aria-label',`Inspect ${album.display_name}, class ${id}`);button.onclick=()=>selectClass(box.id);button.onfocus=()=>showMapPhotoCredit(box.id); + if(!image){image=new Image();image.decoding='async';} + image.draggable=false;image.alt=album.display_name;image.style.height=size+'px'; + button.append(image,photoText('span',id));$('map-thumbnail-layer').append(button); + mapThumbLayout.push(box); // Placeholders have the same culled footprint. + const okay=(image.complete&&image.naturalWidth>0)||await loadReferenceImage(image,photo.image_path,signal); + if(signal.aborted||generation!==mapThumbGeneration)return; + if(okay){loaded++;mapThumbImages.delete(photo.image_path);mapThumbImages.set(photo.image_path,image);while(mapThumbImages.size>128)mapThumbImages.delete(mapThumbImages.keys().next().value);} + else{button.remove();mapThumbLayout=mapThumbLayout.filter(b=>b!==box);mapThumbFailures.add(id);unavailable++;} + status();if(box.id===selected)showMapPhotoCredit(selected); + }catch(error){if(signal.aborted)return;mapThumbFailures.add(id);unavailable++;status();} + } + if(generation===mapThumbGeneration){status();showMapPhotoCredit(selected);} +} +for(const name of ['map-photos','map-photo-size','map-photo-density','map-photo-overlap'])$(name).oninput=drawProjection; +$('map-photo-retry').onclick=()=>{mapThumbFailures.clear();scheduleMapThumbnails();}; +new ResizeObserver(scheduleMapThumbnails).observe(projectionCanvas); +scheduleMapThumbnails(); diff --git a/tests/utils/test_prototype_projection.py b/tests/utils/test_prototype_projection.py index cc9f2731..5ce30f65 100644 --- a/tests/utils/test_prototype_projection.py +++ b/tests/utils/test_prototype_projection.py @@ -41,3 +41,25 @@ def test_degenerate_projection_and_distance_ties_are_explicit_and_finite(): assert plane["variance_fraction"] == [0.0, 0.0] assert np.isfinite(plane["coordinates"]).all() assert plane["retained_fraction"] == [1.0, 1.0] + + +def test_angular_projection_uses_repository_geometry_and_reports_fidelity(): + from dev.prototype_space.explore import weight_model + from dev.prototype_space.projection import angular_distances, angular_tsne + from mini_trainer.modeling import class_similarity + + weights = torch.randn(36, 8, generator=torch.Generator().manual_seed(81)) + z = class_similarity(weight_model(weights), cdf=False)[0].numpy() + unit = (weights / weights.norm(dim=1, keepdim=True)).numpy() + reference = np.arccos(np.clip(unit @ unit.T, -1 + 1e-7, 1 - 1e-7)) + np.fill_diagonal(reference, 0) + np.testing.assert_allclose(angular_distances(z, 8), reference, atol=5e-7) + np.fill_diagonal(z, -np.inf) + neighbours = np.argsort(-z, axis=1, kind="stable")[:, :4].tolist() + np.fill_diagonal(z, 0) + result = angular_tsne(z, 8, neighbours, iterations=300) + assert np.isfinite(result["coordinates"]).all() + assert result["variance_fraction"] is None + expected = [len(set(a).intersection(b)) / 4 for a, b in zip(neighbours, result["neighbours"])] + assert result["retained_fraction"] == expected + assert result["parameters"]["metric"] == "precomputed angular radians" From 9a0e051ed3d5f361ed89b4ae7fce2ce93f277cd9 Mon Sep 17 00:00:00 2001 From: asgersvenning Date: Thu, 10 Sep 2026 23:35:20 +0200 Subject: [PATCH 08/34] docs: review hyperspherical prototype visualization methods --- dev/prototype_space/README.md | 4 + docs/hyperspherical-visualization.md | 486 +++++++++++++++++++++++++++ 2 files changed, 490 insertions(+) create mode 100644 docs/hyperspherical-visualization.md diff --git a/dev/prototype_space/README.md b/dev/prototype_space/README.md index f62aadb9..8a973330 100644 --- a/dev/prototype_space/README.md +++ b/dev/prototype_space/README.md @@ -59,6 +59,10 @@ The environment needs the existing visualization dependencies (including SciPy). ## Spatial projection +See the [hyperspherical visualization review](../../docs/hyperspherical-visualization.md) +for the geometry, measured compression losses, projection alternatives, Python +and browser implementations, and proposed anchor/slice experiments. + The map projects all prototype directions into a common 2D coordinate system. Choose **Angular t-SNE**, **PCA 1–2** or **PCA 3–4**, scroll to zoom, drag to pan, and click a point to update the existing inspector and image browser. **Fit selected neighbourhood** diff --git a/docs/hyperspherical-visualization.md b/docs/hyperspherical-visualization.md new file mode 100644 index 00000000..c0aac22e --- /dev/null +++ b/docs/hyperspherical-visualization.md @@ -0,0 +1,486 @@ +# Visualizing prototype packing on a unit hypersphere + +Research review, 2026-09-10. Scope: mathematical methods, Python implementations, +and browser rendering for the prototype explorer. This is a research deliverable; +methods described as candidates have not been added to training or the explorer. + +The unit sphere is the geometry in which we interpret the observed prototypes. +It does not prescribe their distribution, require clusters, imply uniformity, or +establish a lower-dimensional latent surface. Evaluate a display against the +actual weights and the existing mini_trainer diagnostics. Synthetic arrangements +are controlled tests of what a method reveals or distorts. + +## 1. Separate geometry, dimensional reduction, and rendering + +There are three separate decisions: + +| Layer | Question | Examples | +| --- | --- | --- | +| Source geometry | What relationships do the weights define? | Angular separation, directional neighbours, spherical caps | +| Reduction or slice | Which relationships will the display retain? | Anchor distances, angular t-SNE, spherical stress, a great-sphere slice | +| Rendering | How are the resulting coordinates shown? | Plane, rotatable globe, cartographic map, culled photo labels | + +An angular input metric does not imply spherical output. Conversely, placing a +layout on a globe does not establish that it preserves the original angles. +Both planar and spherical layouts can be useful when their losses are measured. + +For effective weight rows, let + +\[ +u_i=w_i/\|w_i\|,\quad c_{ij}=u_i^\top u_j,\quad +\theta_{ij}=\arccos(c_{ij}). +\] + +Cosine dissimilarity, chord distance, and angular distance are related by + +\[ +1-c_{ij}=2\sin^2(\theta_{ij}/2),\qquad +\|u_i-u_j\|=2\sin(\theta_{ij}/2). +\] + +These give identical exact neighbour orderings, including ties. They do **not** +give identical distance weights, kernels, stress objectives, or fitted layouts. +Cosine dissimilarity is also not generally a metric satisfying the triangle +inequality. Changing it to angular distance is more than changing axis labels. + +### Repository baseline + +The explorer loads effective weights through the existing classifier +parametrization and calls the repository distance functions. See +[the numerical contract](../dev/prototype_space/README.md#numerical-contract), +[distance.py](../mini_trainer/modeling/distance.py), and +[the transform](../mini_trainer/utils/_core/math.py). + +The current pre-CDF transform is, algebraically, + +\[ +z_{ij}=\sqrt{D-2}\,(\pi/2-\theta_{ij}^{\rm clamped}). +\] + +Thus `pi/2 - z/sqrt(D-2)` recovers the baseline's clamped angles without +inverting a saturated CDF. It does not recover distinctions already lost to the +cosine clamp. Self-distances are explicitly zeroed. Baseline class distance, +pre-CDF z, and log-tail displays remain separately identified. + +For probability diagnostics, retain direct log-domain evaluation and separate +colour clipping from numerical values. A projection cannot repair CDF saturation, +and a better colour window cannot repair projection distortion. + +### What the real checkpoint establishes + +Checkpoint: `best_global-lepi-production-w32-1_epoch4.pt`. +SHA256: `42d50f56cb0c6e3ee17ae64fbbcc4eb2ae24335ffe7080197c479c130fff5a81`. +It contains 12,632 prototype directions in 1,280 coordinates, hence on +\(S^{1279}\). The observed effective norms are within about \(2.4\times10^{-7}\) +of one; biases are zero. + +| Measurement | Observed value | Interpretation | +| --- | --- | --- | +| PC1–2 directional variance | 0.6784% | Two linear coordinates retain little total variation | +| PC1–2 mean top-12 neighbour recall | 0.6551% | This particular plane loses most original neighbours | +| Angular t-SNE mean top-12 recall | 48.6212% | A useful measured improvement for the tested settings | +| Linear components for 50%, 80%, 90%, 95%, 99% variance | 354, 794, 1,003, 1,128, 1,246 | Quantifies linear compression loss | +| Centred spectral participation ratio | 901.76 | Descriptive spectrum summary, not intrinsic dimension | +| Mean resultant length | 0.01822 | Describes first-moment cancellation, not uniformity | + +Across classes, original-space angular separations have these quantiles: + +| Neighbour rank | 10th percentile | Median | 90th percentile | +| --- | --- | --- | --- | +| 1 | 58.32° | 66.29° | 73.21° | +| 5 | 70.88° | 74.83° | 77.65° | +| 12 | 75.64° | 77.93° | 79.85° | +| 32 | 79.53° | 80.89° | 82.21° | + +Nearest neighbours are therefore not automatically a small-angle patch. This +matters when assessing tangent approximations; it does not invalidate their +neighbourhood relationships or imply that a different packing was intended. +The spectrum does not rule out every nonlinear representation. + +The t-SNE result uses angular inputs, seed 42, perplexity 30, 1,000 iterations, +random initialization, automatic learning rate and Barnes–Hut angle 0.5. It is +one fitted comparison, not a method ranking across seeds or configurations. +Existing payloads are in ignored `tmp/prototype-report/`; additional descriptive +measurements are in `tmp/hypersphere-research/geometry.json`. + +## 2. Views that preserve specific geometric facts + +### Anchor-centred angular atlas + +Select a class direction \(a\). Place every displayed prototype at planar radius +\(r_i=\theta(a,u_i)\), with rings labelled in degrees. Radius then has an exact +meaning under the chosen angular convention. Bearings are a separate display +choice: a projected tangent direction or an optimized arrangement. + +For \(0<\theta<\pi\), the spherical logarithm is + +\[ +\log_a(u)=\frac{\theta}{\sin\theta}(u-\cos\theta\,a), +\qquad \|\log_a(u)\|=\theta. +\] + +With a two-column orthonormal tangent basis \(B\), let +\(q_i=B^\top\log_a(u_i)\). Ordinary tangent projection uses \(q_i\) and generally +shrinks radii. An exact-radius display instead uses +\(y_i=\theta_i q_i/\|q_i\|\). This preserves distances **to the anchor only**; +bearings and distances between other points remain lossy. If \(q_i=0\), its +bearing is unresolved and must be handled explicitly. The anchor maps to zero; +an antipode has no unique logarithm/bearing. + +This is especially suitable for photo browsing: selected class at the centre, +angular rings, true neighbours highlighted, thumbnails culled without moving +their coordinates. Switching anchors produces an atlas of partial views. It +does not require selecting a global mean. The formulas can use NumPy/PyTorch; +[Geomstats supplies hypersphere log/exp operations](https://geomstats.github.io/_modules/geomstats/geometry/hypersphere.html). + +### Two-anchor distance coordinates + +Plot \((\theta(a,u_i),\theta(b,u_i))\). Both coordinates are directly interpretable, +although many different directions can coincide. Triangle inequalities restrict +the feasible region, providing a useful diagnostic check. This offers a concrete +way to inspect a close pair, a cross-tree connection, or two competing directions. +It is a distance plot, not a complete spatial embedding. + +### Great-sphere slices of directional decision regions + +Choose orthonormal columns \(Q\in\mathbb R^{D\times3}\). Directions +\(x=Qs\), \(s\in S^2\), form a genuine great two-sphere in the original space. +On that slice, + +\[ +u_i^\top x=(Q^\top u_i)^\top s. +\] + +Precompute three coefficients per class, then colour each sampled slice direction +by the largest dot product. This evaluates the nearest-prototype directional +partition exactly on the slice, up to numerical and sampling resolution. +**Do not normalize \(Q^\top u_i\)**: doing so changes the competition. + +An anchor and two chosen tangent directions define a useful navigable slice. +Two-dimensional subspaces instead give great-circle sweeps. Adjacent cells, +winning-score margins and transitions can be inspected with GBIF image labels. +Some classes have no winning region in a chosen slice; that is a property of +the slice and must not be presented as global absence. + +Slice areas are not high-dimensional cell volumes. These are geometric +nearest-direction regions; reproducing the complete hierarchical classifier's +decision rule would require its actual scoring and inference path. Evaluate the +cost of `number_of_classes × number_of_pixels` before interactive full-resolution +rendering; tiles, coarse previews and batched matrix products are natural options. + +### Packing profiles alongside any map + +Compute original-space cap counts +\(N_i(r)=\#\{j\ne i:\theta_{ij}\le r\}\), neighbour radii \(r_{k,i}\), and +mutual-neighbour links. These expose local packing without treating 2D point +density as spherical density. Display distributions, selected-class curves and +linked outliers. A random/uniform reference can be an explicitly selected +comparison; it is not needed to define these measurements. + +## 3. Dimensional-reduction families + +| Method | What it fits or preserves | Useful role here | Main limitation | +| --- | --- | --- | --- | +| Centred PCA | Ambient squared reconstruction error | Measured linear reference and dominant directional variation | Optimizes neither angular error nor neighbour recall | +| Great-subspace projection | Directions projected into a low-rank linear subspace, optionally renormalized | Simple spherical reference with residuals | Renormalizing a tiny projection can amplify poorly retained directions | +| Tangent PCA / principal geodesic analysis | Variation expressed around a base point / fitted geodesic subspaces | Anchor or regional analysis | Linearized and exact geodesic objectives differ; base point and angular extent matter | +| Principal nested spheres | Successive fitted lower-dimensional great or small subspheres | Probe curved modes that great subspheres miss | Model flexibility, sequential fitting cost and residual loss need assessment | +| Angular t-SNE | Neighbour-affinity agreement in a plane | Already useful class navigation | Map areas and long-range gaps are not angular measurements | +| Angular-input UMAP | Fuzzy neighbour graph fitted to a chosen output geometry | Compare graph structure at several neighbourhood scales | Local rescaling changes what visible density means | +| Spherical stress / MDS | Explicit pairwise-distance objective on \(S^2\) | A globe with a declared angular fidelity target | Global/local weighting tradeoffs and optimization cost | +| Spherical-output UMAP | Neighbour graph embedded with a spherical output distance | A practical first optimized-globe experiment | Spherical output alone does not preserve source packing | +| DOSNES | Neighbour embedding after doubly stochastic affinity normalization | An informative alternative normalization experiment | Equalizes affinity mass, potentially hiding packing differences of interest | + +### PCA, tangent PCA and principal geodesic analysis + +PCA is mathematically well defined for unit vectors in ambient Euclidean space. +Its objective simply answers a different question from angular fidelity. Retain +it as a baseline with variance and neighbour measurements. + +For a great-subspace projection, inspect both the retained projection norm and +the angular residual to the subspace. Renormalization onto a display sphere +should not conceal the lost component. Centring first changes the geometry: +centred PCA coordinates are not automatically a great-sphere projection. + +Tangent PCA takes logarithms at a base point and performs linear PCA there. +Principal geodesic analysis more generally seeks geodesic subspaces; its exact +projection objective is not identical to this linearization. A fitted intrinsic +mean can be useful, but its uniqueness and stability cannot be assumed from +unit norms. An extrinsic mean direction is also different from an intrinsic +Fréchet mean. The observed small resultant alone does not settle either issue. +[Fletcher et al., 2004](https://www.cs.jhu.edu/~misha/ReadingSeminar/Papers/Fletcher04.pdf). + +### Principal nested spheres (PNS) + +PNS works backwards through successively lower-dimensional subspheres. Allowing +small spheres captures latitude-like curved variation that great subspheres can +miss. This is a fitted representation of the observations, not evidence that +the training objective should produce such a structure. Compare great-only and +small-sphere variants using held-out reconstruction and angular/neighbour errors. +The original work explicitly discusses small-sphere overfitting and selection +procedures; their statistical assumptions should be reviewed separately before +adoption. [Jung, Dryden and Marron, 2012](https://pmc.ncbi.nlm.nih.gov/articles/PMC3635703/). + +The direct Python candidate is +[scikit-pns](https://github.com/JSS95/scikit-pns), with `skpns.IntrinsicPNS` and +`ExtrinsicPNS`, fitting, transformation and inverse transformation. PyPI reported +stable version 1.3.0 during this review. Check the selected release against the +documentation before implementation; do not assume `latest` documentation and +stable package are identical. Sequential fitting through 1,280 input coordinates +needs a real runtime/memory pilot. + +[torch-pns](https://github.com/JSS95/torch-pns/blob/master/src/torchpns/intrinsic.py) +currently exposes `torchpns.InverseIntrinsicPNS`: a PyTorch inverse transform +constructed from a fitted scikit-pns estimator. It is not a GPU PNS fitting +implementation. Its source also records an intentional formula difference from +the referenced inverse implementation, making cross-library round-trip checks +necessary before relying on it. + +### Optimizing an actual spherical layout + +For display directions \(y_i\in S^2\), an explicit angular-stress candidate is + +\[ +L(Y)=\sum_{(i,j)\in E}w_{ij} +\left[\arccos(y_i^\top y_j)-\theta_{ij}\right]^2. +\] + +The pair set and weights define the question: all pairs, neighbours, or a disclosed +mixture of neighbours and sampled distant pairs. If sampling estimates an all-pair +objective, use the appropriate sampling weights; otherwise call it a deliberately +reweighted objective. A fitted global distance scale is another explicit option, +not an unnoticed rescaling of angular units. + +A smoother alternative fits dot products, +\(\sum w_{ij}(y_i^\top y_j-\cos\theta_{ij})^2\). It avoids `acos` gradients but +weights angular discrepancies differently, especially near zero and pi. Neither +objective guarantees a unique layout. Compare seeds and report residuals. +Spherical MDS has concrete research implementations in graph drawing, although +graph shortest-path distance and our angular input distance are different inputs. +[Miller, Huroyan and Kobourov, 2022](https://arxiv.org/abs/2209.00191). + +Exact preservation of every angle on a globe is impossible for this matrix: +it would require `U @ U.T == Y @ Y.T`, but the latter has rank at most three and +the observed source has many more independent directions. This is an algebraic +limitation of the display, independent of any model for how prototypes are packed. + +For custom optimization, +[Pymanopt's `Oblique(3, N)`](https://pymanopt.org/docs/stable/manifolds.html#oblique-manifold) +represents N unit columns, hence N display points. `Sphere(N, 3)` instead imposes +one Frobenius-norm constraint on the whole matrix and is the wrong constraint. +[Geoopt's sphere implementation](https://geoopt.readthedocs.io/en/latest/_modules/geoopt/manifolds/sphere.html) +offers a PyTorch route with the last axis representing a sphere point. Inspect +its dtype-dependent distance clamps before declaring parity with repository +angles. These are optimization building blocks, not turnkey validated layouts. + +### UMAP, t-SNE and density-oriented variants + +[UMAP input metrics](https://umap-learn.readthedocs.io/en/latest/parameters.html) +include cosine and precomputed distances. Using normalized vectors with cosine +is a sensible comparator; using the baseline-derived angular matrix is the +cleanest initial metric-controlled experiment. Monotone input transformations +retain neighbour order but change the fuzzy edge weights. + +[Spherical output is explicitly supported](https://umap-learn.readthedocs.io/en/latest/embedding_space.html) +through `output_metric="haversine"`. The output has two angular coordinates; +`n_components=3` alone would normally mean a Euclidean 3D layout, not a sphere. +The documented conversion to XYZ is +`(sin(t)*cos(p), sin(t)*sin(p), cos(t))`. + +There is a significant implementation convention: +[UMAP's source](https://github.com/lmcinnes/umap/blob/master/umap/distances.py) +uses latitude/longitude in `haversine`, but a pi/2 shift in +`haversine_grad`, which is the registered output metric with gradient. Its +first output coordinate therefore follows the colatitude convention used in +the tutorial. Test known poles, quarter-circle pairs, seams and the XYZ distance +identity against the pinned version. Do not reuse input latitude conversion +uncritically for output coordinates. + +The existing [scikit-learn t-SNE](https://scikit-learn.org/stable/modules/generated/sklearn.manifold.TSNE.html) +path already gives an angular-input planar reference. +[openTSNE](https://opentsne.readthedocs.io/en/stable/api/affinity.html) adds useful +affinity controls, including multiscale affinities and `PrecomputedAffinities`. +The latter expects affinities, not a raw angular-distance matrix. Neither is a +drop-in spherical-output t-SNE implementation. + +[densMAP](https://umap-learn.readthedocs.io/en/latest/densmap_demo.html) is worth a +controlled comparison when preserving local density is a display objective. +Its density regularization does not turn screen areas into high-dimensional +spherical volumes. Compare it with actual cap counts and neighbour radii. +UMAP's local normalization is part of its construction, not an empirical finding +about these weights. [UMAP paper](https://arxiv.org/abs/1802.03426). + +[DOSNES](https://yaolubrain.github.io/dosnes/) explicitly embeds on spheres after +making affinities doubly stochastic. For this task, the normalization is a major +scientific choice: equal affinity mass can remove variation we want to inspect. +The author's [repository](https://github.com/yaolubrain/DOSNES) contains MATLAB +fitting and a JS viewer and links a [separate Python port](https://github.com/Coni63/DOSNES). +That port documents limited checking and discrepancies from the MATLAB results. +Treat it as research code requiring verification, not the first production path. + +Graph-distance methods such as Isomap and diffusion-based displays are additional +questions one can ask of a neighbour graph. Their graph path or diffusion +distances must be labelled separately from direct great-circle separation. +They are not needed to respect the unit-sphere input constraint. + +## 4. Cartography applies after reduction to S² + +Stereographic projection of \(S^{1279}\) gives 1,279 Euclidean coordinates, not +two. A globe or map still needs dimensional reduction, or an actual 2D slice. +Conformality of a later cartographic step does not undo losses in that reduction. + +For an actual unit two-sphere, these azimuthal projections use angular distance +\(\theta\) from the map centre and retain bearing: + +| Projection | Planar radius | Preserves | Display consequence | +| --- | --- | --- | --- | +| Orthographic | \(\sin\theta\) | Appearance from an infinitely distant camera | Visible hemisphere only; strong limb compression | +| Stereographic | \(2\tan(\theta/2)\) | Local angles | Antipode at infinity; large area distortion | +| Gnomonic | \(\tan\theta\) | Great circles as straight lines | Only an open hemisphere; diverges at 90° | +| Azimuthal equidistant | \(\theta\) | Distances from map centre | Other distances and areas distorted | +| Lambert azimuthal equal-area | \(2\sin(\theta/2)\) | Two-sphere area | Shapes and most distances distorted | + +These are unit-sphere formulas, derived with unit scale at the centre. +[Snyder's USGS manual](https://pubs.usgs.gov/publication/pp1395) is the primary +reference for their geometry and domains. + +For a radial map of the full \(S^{D-1}\) into a \((D-1)\)-dimensional tangent +space, the volume scaling away from singularities is +`r'(theta) * (r(theta)/sin(theta))**(D-2)`. This follows by dividing the radial +volume elements. A two-sphere equal-area projection therefore does not promise +volume preservation for the original 1,280-coordinate prototypes. + +[D3-geo](https://d3js.org/d3-geo/azimuthal) supplies these projections, with +rotation and clipping. Its [coordinate interface](https://d3js.org/d3-geo/projection) +uses `[longitude, latitude]` in degrees. Keep this distinct from radians, +colatitude and XYZ used in numerical libraries. + +## 5. Concrete implementation and rendering choices + +### Python and computation + +| Tool | Verified capability | Appropriate experiment | +| --- | --- | --- | +| Existing NumPy/PyTorch + sklearn | Current effective-weight extraction, baseline angles, PCA and angular t-SNE | Reuse the numerical foundation; implement anchor/slice formulas directly | +| [Geomstats](https://geomstats.github.io/getting_started/first-steps.html) | Hypersphere geometry, `TangentPCA`; NumPy and optional PyTorch backends | Validate log/exp and investigate regional tangent coordinates | +| [scikit-pns](https://scikit-pns.readthedocs.io/en/latest/?badge=latest) | Intrinsic/extrinsic nested-sphere estimators | Small pilot followed by measured scaling to the full matrix | +| Pymanopt / Geoopt | Sphere-constrained optimization primitives | Explicit angular or dot-product stress on a display sphere | +| umap-learn | Custom/precomputed input distances and non-Euclidean output metrics | Compare planar and spherical output with controlled inputs | +| openTSNE | Flexible affinity construction | Multiscale planar comparison without silently changing source geometry | +| [RAPIDS cuML UMAP](https://docs.rapids.ai/api/cuml/legacy/api/generated/cuml.manifold.umap/) | GPU UMAP, cosine metric and neighbour-graph inputs | Larger planar experiments when GPU cost is justified | + +The reviewed cuML interface does not expose Python UMAP's `output_metric`; do +not assume spherical-output parity. Its API documentation also needs careful +version-specific reading for pairwise versus precomputed-neighbour inputs. +There is no performance claim here for these packages on this checkpoint. + +### Browser and image labels + +| Framework | Strength | Integration limit | +| --- | --- | --- | +| Existing Canvas + thumbnail overlay | Already supports this checkpoint, linked inspection and bounded photo requests | Extend first for anchor views; benchmark before replacing | +| [Three.js](https://threejs.org/docs/pages/SpriteMaterial.html) | Rotatable 3D globe, sprite image labels; `sizeAttenuation` control | Need hemisphere/depth handling, screen-space culling, picking and image budgets | +| [deck.gl IconLayer](https://deck.gl/docs/api-reference/layers/icon-layer) | GPU image icons, atlases and pixel-sized billboards | Its collision extension is not equivalent to our rectangle-overlap rule | +| [PAIR ScatterGL](https://github.com/PAIR-code/scatter-gl) | 2D/3D scatter, sprite-sheet images, selection and camera callbacks | Verify pinned dependency integration and atlas/culling behaviour | +| [regl-scatterplot](https://github.com/flekschas/regl-scatterplot) | WebGL planar point rendering and interaction | Add a separate bounded photo-label layer | +| D3-geo | Cartographic projection, rotation, clipping | Handles a two-sphere/map, not 1,280-dimensional reduction | + +[deck.gl CollisionFilterExtension](https://deck.gl/docs/api-reference/extensions/collision-filter-extension) +checks feature anchors against rendered coverage and supports collision +priorities. That differs from testing the allowable fraction of overlap between +thumbnail rectangles. Preserve the current size/density/overlap semantics unless +a deliberate UI change is made. Transparent icon anchors also need special care +under its documented alpha handling. + +[UMAP-JS](https://github.com/PAIR-code/umap-js) has custom input `distanceFn`, +asynchronous fitting and incremental steps. The reviewed API does not provide +Python UMAP's spherical output option; its README also notes differences in +initialization and specialized metric handling. Async fitting is not by itself +a worker-thread guarantee. Initially, fit in Python and serve coordinates; use +a worker if browser-side fitting later becomes useful. + +For every renderer, a thumbnail is a class image label anchored to a numerical +point. Culling, visual size and overlap may change label visibility but must not +move prototypes or change their distances. A globe adds back-side occlusion to +the existing viewport and overlap tests. Preserve photo provenance and cache +budgets, and expose point counts even where image labels are culled. + +## 6. Validation before choosing a default + +1. **Geometry contract:** use the checkpoint's effective rows and exact class + order. Compare against baseline z-derived angular distances, with the clamp + and self-pair conventions recorded. Test identical, orthogonal, antipodal and + nearly coincident directions. Verify every coordinate conversion separately. +2. **Neighbour fidelity:** recall at k = 1, 5, 12, 32 and 64; show per-class + distributions and poor cases as well as a mean. Use original full-population + neighbours even when selecting a subset of anchors for evaluation. +3. **Distance fidelity:** angular errors and Shepard plots on both near pairs + and uniformly sampled unordered pairs. Report strata separately so the many + distant pairs do not conceal failures among neighbours. Specify any scale + fitting and distinguish it from absolute angular preservation. +4. **Two sources of display loss:** for spherical layouts, evaluate distances on + the output sphere first, then the additional camera/cartographic distortion. + Screen-space neighbour recall alone cannot evaluate a globe's geometry. +5. **Packing fidelity:** compare local radii and cap counts to apparent density. + Do not label a dense t-SNE patch or a large slice cell as a measured + high-dimensional volume. Inspect mutual neighbours and cross-tree links. +6. **Stability and cost:** multiple seeds, neighbourhood scales, optimization + traces, peak memory, fit time and interactive latency. A layout change between + checkpoints needs rotation/alignment treatment; compare original distances + and neighbour changes regardless of display alignment. +7. **Synthetic probes:** algebraic edge cases, narrow caps, small-circle modes, + uneven local densities, separated groups and independent directions. Match + N and D when investigating packing scales. The existing six-point fixture + with k=5 trivially retains every neighbour; 100% there is not evidence of a + faithful projection. + +No full N-by-N objective is automatically cheap: this checkpoint has 79,777,396 +unordered off-diagonal pairs and about 638 MB per dense float32 square matrix +(609 MiB), before copies and working arrays. Offline projection, sparse retained +graphs, sampled diagnostics and periodic training-time summaries have different +cost boundaries. Keep them explicit. + +## 7. Recommended next increments + +**First: an exact-radius anchor atlas.** It extends the current photo browser, +answers a precise angular question and requires no new package. Add selectable +bearings, angular rings, unresolved-bearing handling and original-neighbour +overlays. Show that radii are exact while bearings are reduced. + +**Second: a metric-controlled globe comparison.** Fit angular-input UMAP to a +plane and to spherical output, alongside the existing t-SNE. Add a small custom +spherical-stress pilot if fidelity warrants it. Inspect both original-angle +errors and neighbour recall before deciding whether a globe helps browsing. +Use Three.js for a genuinely rotatable sphere, with the current thumbnail policy. + +**Third: interactive great-circle/great-sphere slices.** These address the +partition question directly, complementing the neighbour atlas and compressed +global map. Retain all prototype coefficients when evaluating winning regions; +photo labels identify the classes appearing in the slice. + +PNS is a worthwhile research comparator after a bounded numerical/scaling pilot. +Its fitted subspheres answer a different question from browsing arbitrary +observed packing. No choice above changes the training constraint, baseline +distance transform, dendrogram, or colour semantics. + +## 8. Evidence and reproducibility boundary + +This review inspected primary papers, official documentation and selected +upstream source. Geomstats, Pymanopt, Geoopt, scikit-pns, torch-pns, openTSNE, +UMAP and cuML were not installed or runtime-tested in the shared environment. +Source inspection establishes the cited interface facts, not package parity, +scaling, numerical correctness, GPU support of every path or browser performance. +Pin versions and isolate dependencies when starting those experiments. + +The PCA/t-SNE measurements come from the existing runnable explorer and its +stored real-checkpoint payload. The additional spectrum uses float32 normalized +effective rows, centring without whitening, and eigenvalues of `X.T @ X`. +Participation ratio is `(eigenvalues.sum()**2 / eigenvalues.square().sum())`; +component counts use cumulative nonnegative eigenvalues. Mean resultant is the +norm of the average normalized row. Angular quantiles invert the existing +stored z profiles. Medians use `torch.median` (lower middle for even counts). + +Source URLs using `latest`, `stable`, `master` or `main` are moving references, +reviewed on the date above. Before implementation, record the exact package +versions/commits used and validate coordinate and numerical contracts against +them. Research references do not override the empirical baseline. From d3b1c11b446d34a96b6bf9c3b256cbcdf291be47 Mon Sep 17 00:00:00 2001 From: asgersvenning Date: Thu, 10 Sep 2026 23:38:29 +0200 Subject: [PATCH 09/34] feat: add compact and separated prototype thumbnails --- dev/prototype_space/README.md | 22 ++++++-- dev/prototype_space/check_browser.mjs | 23 +++++++++ dev/prototype_space/report.html | 4 +- dev/prototype_space/thumbnails.js | 72 +++++++++++++++++++++------ docs/hyperspherical-visualization.md | 6 ++- 5 files changed, 102 insertions(+), 25 deletions(-) diff --git a/dev/prototype_space/README.md b/dev/prototype_space/README.md index 8a973330..9a5728aa 100644 --- a/dev/prototype_space/README.md +++ b/dev/prototype_space/README.md @@ -113,9 +113,21 @@ Enable **GBIF thumbnails on map** independently of the image-card panel. Configu image size (32–160 CSS pixels), density (5–200 images per megapixel of map viewport) and allowable pairwise rectangle overlap (0–75%). The density gives a maximum budget, capped at 200 images; viewport and overlap culling can produce fewer. -The overlap fraction uses the full thumbnail rectangle including its class label. - -Images stay centered on actual projected points. Culling prioritizes the selected +The overlap fraction uses the full thumbnail rectangle including its class label +when labels are enabled. Disable **Labels beneath images** for square image-only +thumbnails with no caption, border or padding; images fill the square by cropping +as needed. Names, IDs and credits remain available on hover or keyboard focus. + +Images initially stay centered on actual projected points. Enable **Push thumbnails +apart** to admit candidates using the overlap setting, then separate their +rectangles with bounded pairwise repulsion. Images may move up to twice their +configured size in screen pixels. Anchor markers and leader lines identify the +fixed prototype positions. Unresolved collisions are culled in priority order, +so displayed rectangles do not overlap. The status reports the remaining slots +and collision culls. Increase allowable overlap to try a denser candidate set; +this does not guarantee that every candidate will fit after separation. + +Culling prioritizes the selected class and its original-space neighbours, then uses a stable mixed class order. Only fully visible, admitted rectangles trigger image requests. Pan/zoom cancels stale loads, hides stale placements immediately, and recomputes after a short @@ -127,8 +139,8 @@ failed lookups. Synthetic cases make no photo requests. Click a thumbnail to inspect the class. Hover or keyboard focus exposes the scientific name, exact ID, creator, image license and source links below the map. -Culling never changes scores, coordinates or neighbour ranks. The controls affect -only which image labels are displayed. +Culling and image repulsion never change scores, prototype coordinates or +neighbour ranks. The controls affect image labels and their displayed positions. ## Class image labels diff --git a/dev/prototype_space/check_browser.mjs b/dev/prototype_space/check_browser.mjs index abcfc927..ed549cc6 100644 --- a/dev/prototype_space/check_browser.mjs +++ b/dev/prototype_space/check_browser.mjs @@ -83,12 +83,35 @@ try{ check(thumbnailLayout(fixturePoints,[0,1,2,3],{...config,overlap:.5}).boxes.length===3,'thumbnail allowable overlap'); check(thumbnailLayout(fixturePoints,[0,1,2,3],{...config,density:5}).boxes.length===1,'thumbnail density budget'); check(thumbnailLayout(fixturePoints,[0,1,2,3],{...config,size:140}).boxes.length===1,'thumbnail size changes culling'); + const compact=thumbnailLayout([[32,32],[96,32]],[0,1],{...config,width:128,height:64,labels:false,density:2000}); + check(compact.boxes.length===2&&compact.boxes.every(b=>b.width===64&&b.height===64),'image-only culling has no label or padding footprint'); + const originalPoints=JSON.stringify(fixturePoints),pushed=thumbnailLayout(fixturePoints,[0,1,2,3],{...config,overlap:.5,push:true}); + check(pushed.boxes.length===3&&pushed.boxes.some(b=>b.x!==b.anchorX||b.y!==b.anchorY),'push apart retains and separates overlapping candidates'); + const separate=boxes=>boxes.every((b,i)=>boxes.slice(i+1).every(other=>thumbnailSharedArea(b,other)<1e-8)); + check(separate(pushed.boxes),'pushed rectangles do not overlap'); + check(pushed.boxes.every(b=>b.left>=0&&b.top>=0&&b.left+b.width<=config.width&&b.top+b.height<=config.height&&Math.hypot(b.x-b.anchorX,b.y-b.anchorY)<=2*config.size+1e-8),'push apart respects viewport and displacement bounds'); + check(JSON.stringify(fixturePoints)===originalPoints&&JSON.stringify(thumbnailLayout(fixturePoints,[0,1,2,3],{...config,overlap:.5,push:true}))===JSON.stringify(pushed),'push apart is deterministic and preserves source points'); + const dense=Array.from({length:60},(_,i)=>[40+(i%10)*18,40+Math.floor(i/10)*18]); + const tight=thumbnailLayout(dense,dense.map((_,i)=>i),{...config,width:260,height:180,labels:false,overlap:.75,push:true,density:2000}); + check(tight.boxes.length>0&&separate(tight.boxes)&&tight.boxes.every(b=>b.left>=0&&b.top>=0&&b.left+b.width<=260&&b.top+b.height<=180),'crowded boundary fixture resolves or culls collisions'); document.getElementById('map-photos').checked=true;scheduleMapThumbnails();clearTimeout(mapThumbTimer);await renderMapThumbnails(); check(document.querySelectorAll('.map-thumb').length>0,'map thumbnails load'); check([...document.querySelectorAll('.map-thumb img')].every(img=>img.naturalWidth>0),'map thumbnails decode'); const first=mapThumbLayout[0];check(mapThumbnailHit([first.x*1200/projectionCanvas.getBoundingClientRect().width,first.y*600/projectionCanvas.getBoundingClientRect().height])===first.id,'thumbnail hit testing'); showMapPhotoCredit(first.id);check(document.getElementById('map-photo-credit').textContent.includes('Fixture photographer'),'map thumbnail attribution'); document.querySelector('.map-thumb').click();check(selected===first.id,'thumbnail selection updates class'); + const coordinatesBefore=JSON.stringify(projectionHits); + document.getElementById('map-photo-labels').checked=false;document.getElementById('map-photo-push').checked=true;document.getElementById('map-photo-overlap').value='75';document.getElementById('map-photo-density').value='200'; + scheduleMapThumbnails();clearTimeout(mapThumbTimer);await renderMapThumbnails(); + check(document.querySelectorAll('.map-thumb').length>0&&[...document.querySelectorAll('.map-thumb')].every(b=>!b.querySelector('span')&&getComputedStyle(b).padding==='0px'&&getComputedStyle(b).borderWidth==='0px'),'image-only toggle removes labels and whitespace'); + check(separate(mapThumbLayout)&&document.getElementById('map-photo-status').textContent.includes('displayed rectangles do not overlap'),'rendered push mode has no overlaps'); + check(JSON.stringify(projectionHits)===coordinatesBefore,'thumbnail relaxation preserves projected coordinates'); + const displaced=mapThumbLayout.find(b=>Math.hypot(b.x-b.anchorX,b.y-b.anchorY)>=.5); + check(displaced&&document.querySelectorAll('.map-thumb-tether').length>0,'displaced photos retain visible anchor markers'); + check(mapThumbnailHit([displaced.x*1200/projectionCanvas.getBoundingClientRect().width,displaced.y*600/projectionCanvas.getBoundingClientRect().height])===displaced.id,'displaced thumbnail hit testing'); + document.getElementById('map-photo-labels').checked=true;document.getElementById('map-photo-push').checked=false;document.getElementById('map-photo-overlap').value='0';document.getElementById('map-photo-density').value='40'; + scheduleMapThumbnails();clearTimeout(mapThumbTimer);await renderMapThumbnails(); + check([...document.querySelectorAll('.map-thumb')].every(b=>b.querySelector('span'))&&document.querySelectorAll('.map-thumb-tether').length===0,'label and fixed-position modes restore'); document.getElementById('map-photos').checked=false;scheduleMapThumbnails();check(document.querySelectorAll('.map-thumb').length===0,'map thumbnail toggle clears images'); const fixtureFetch=window.fetch;let release; diff --git a/dev/prototype_space/report.html b/dev/prototype_space/report.html index 84d79c26..c68a4ff3 100644 --- a/dev/prototype_space/report.html +++ b/dev/prototype_space/report.html @@ -16,7 +16,7 @@ table{border-collapse:collapse;width:100%;font-size:12px}th,td{text-align:left;border-bottom:1px solid var(--line);padding:7px 5px}th{color:var(--muted)}td button{border:0;background:transparent;color:#087781;padding:0;font-size:12px;text-decoration:underline}.mono{font-family:ui-monospace,monospace}.notice{border-left:3px solid #c28936;padding:10px 14px;background:#fff8ec;font-size:13px}details{margin-top:14px}summary{cursor:pointer;font-weight:600}pre{font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere}.panel p{margin-top:0}#search-status{font-size:12px;color:var(--muted)}#cell-info{min-height:44px}.selected-title{font-size:24px}.case-note{margin-bottom:18px}.tree-hit{cursor:pointer}footer{font-size:13px;color:var(--muted);padding-top:10px} @media(max-width:850px){.grid{grid-template-columns:1fr}.stats{grid-template-columns:1fr 1fr}.controls{position:static}main{padding:16px 12px}.panel{padding:17px}header{padding:24px}.controls input{width:160px}} .photo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:14px}.photo-card{border:1px solid var(--line);border-radius:8px;padding:10px;min-width:0}.photo-selected{border:2px solid var(--accent)}.photo-open{width:100%;padding:0;border:0;background:#edf2f5;height:150px;overflow:hidden}.photo-open img{display:block;width:100%;height:100%;object-fit:contain}.photo-title{display:block;margin-top:8px}.photo-credit{font-size:10px;color:var(--muted);overflow-wrap:anywhere;margin:8px 0 0!important}.photo-credit a{color:var(--accent)}.photo-card button:not(.photo-open){font-size:11px;padding:4px 6px} -.map-thumb{position:absolute;transform:translate(-50%,-50%);padding:2px;border:2px solid #087e8b;border-radius:4px;background:white;pointer-events:none;overflow:hidden;box-shadow:0 1px 4px #0003}.map-thumb img{display:block;width:100%;object-fit:contain;background:#f5f7fa}.map-thumb span{display:block;font-size:10px;height:16px;line-height:16px;white-space:nowrap;overflow:hidden}.map-thumb:focus-visible{outline:3px solid #ffae3d} +.map-thumb{position:absolute;transform:translate(-50%,-50%);padding:2px;border:2px solid #087e8b;border-radius:4px;background:white;pointer-events:none;overflow:hidden;box-shadow:0 1px 4px #0003}.map-thumb img{display:block;width:100%;object-fit:contain;background:#f5f7fa}.map-thumb span{display:block;font-size:10px;height:16px;line-height:16px;white-space:nowrap;overflow:hidden}.map-thumb.image-only{padding:0;border:0;border-radius:0;box-shadow:none}.map-thumb.image-only img{object-fit:cover;background:transparent}.map-thumb.image-only:focus-visible{outline-offset:-3px}.map-thumb-tether{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;overflow:visible}.map-thumb:focus-visible{outline:3px solid #ffae3d}
mini_trainer · empirical exploration

Prototype observatory

Move between the global tree, pairwise structure and a class’s local neighbourhood. The existing mini_trainer distance is the reference; the pre-CDF z-score exposes detail that can be hidden by probability rounding.

@@ -33,7 +33,7 @@

Prototype space · spatial projection

Compare linear PCA with angular t-SNE when available. Scroll to zoom, drag to pan, click a point or thumbnail to inspect its class. Each case is fitted separately.

-
+

Hover a point for its class ID.

Red: selected class · teal: its neighbours in the original space · orange rings: nearby in this plane only · grey: other classes. Links follow the original-space ranking. Projected gaps and apparent overlaps can differ from the full embedding geometry.

diff --git a/dev/prototype_space/thumbnails.js b/dev/prototype_space/thumbnails.js index f2489961..ff0ee83e 100644 --- a/dev/prototype_space/thumbnails.js +++ b/dev/prototype_space/thumbnails.js @@ -1,23 +1,60 @@ -/* Image rectangles stay anchored to projected coordinates; culling never moves points. */ +/* Optional label relaxation changes thumbnail positions only, never prototype coordinates. */ let mapThumbTimer = null, mapThumbController = null, mapThumbGeneration = 0; let mapThumbLayout = []; const mapThumbImages = new Map(); const mapThumbFailures = new Set(); -function thumbnailLayout(points, priority, {width,height,size,density,overlap}) { +function thumbnailSharedArea(a,b){ + return Math.max(0,Math.min(a.left+a.width,b.left+b.width)-Math.max(a.left,b.left))*Math.max(0,Math.min(a.top+a.height,b.top+b.height)-Math.max(a.top,b.top)); +} +function relaxThumbnails(boxes,width,height,maxShift){ + const move=(box,dx,dy)=>{ + let x=Math.max(box.width/2,Math.min(width-box.width/2,box.x+dx)); + let y=Math.max(box.height/2,Math.min(height-box.height/2,box.y+dy)); + const distance=Math.hypot(x-box.anchorX,y-box.anchorY); + if(distance>maxShift){x=box.anchorX+(x-box.anchorX)*maxShift/distance;y=box.anchorY+(y-box.anchorY)*maxShift/distance;} + box.x=x;box.y=y;box.left=x-box.width/2;box.top=y-box.height/2; + }; + // Bounded deterministic pairwise repulsion. Any unresolved collisions are + // culled in original priority order, rather than silently retaining overlap. + for(let pass=0;pass<48;pass++){ + let collisions=0; + for(let i=0;ithumbnailSharedArea(box,other)>1e-8))kept.push(box); + return kept; +} +function thumbnailLayout(points, priority, {width,height,size,density,overlap,labels=true,push=false}) { const budget=Math.min(200,Math.max(1,Math.floor(width*height*density/1e6))); - const chosen=[],used=new Set();const boxWidth=size+8,boxHeight=size+24; + const chosen=[],used=new Set();const boxWidth=labels?size+8:size,boxHeight=labels?size+24:size; for(const id of priority){ if(used.has(id))continue;used.add(id);const [x,y]=points[id]; - const box={id,x,y,left:x-boxWidth/2,top:y-boxHeight/2,width:boxWidth,height:boxHeight}; + const box={id,x,y,anchorX:x,anchorY:y,left:x-boxWidth/2,top:y-boxHeight/2,width:boxWidth,height:boxHeight}; if(box.left<0||box.top<0||box.left+boxWidth>width||box.top+boxHeight>height)continue; const area=boxWidth*boxHeight; - const blocked=chosen.some(other=>{ - const shared=Math.max(0,Math.min(box.left+box.width,other.left+other.width)-Math.max(box.left,other.left))*Math.max(0,Math.min(box.top+box.height,other.top+other.height)-Math.max(box.top,other.top)); - return shared/area>overlap+1e-12; - }); + const blocked=chosen.some(other=>thumbnailSharedArea(box,other)/area>overlap+1e-12); if(!blocked)chosen.push(box);if(chosen.length>=budget)break; } - return {boxes:chosen,budget}; + const boxes=push?relaxThumbnails(chosen,width,height,2*size):chosen; + return {boxes,budget,culled:chosen.length-boxes.length}; +} +function thumbnailTether(box){ + if(Math.hypot(box.x-box.anchorX,box.y-box.anchorY)<.5)return null; + const ns='http://www.w3.org/2000/svg',svg=document.createElementNS(ns,'svg'); + svg.classList.add('map-thumb-tether');svg.setAttribute('aria-hidden','true');svg.style.zIndex='201'; + const line=document.createElementNS(ns,'line'); + const edgeX=Math.max(box.left,Math.min(box.left+box.width,box.anchorX)),edgeY=Math.max(box.top,Math.min(box.top+box.height,box.anchorY)); + for(const [key,value] of Object.entries({x1:box.anchorX,y1:box.anchorY,x2:edgeX,y2:edgeY,stroke:'#435b70','stroke-width':1}))line.setAttribute(key,String(value)); + const dot=document.createElementNS(ns,'circle'); + for(const [key,value] of Object.entries({cx:box.anchorX,cy:box.anchorY,r:3,fill:'#ffffff',stroke:'#243b50','stroke-width':1.5}))dot.setAttribute(key,String(value)); + svg.append(line,dot);return svg; } function mapThumbnailHit(point){ const rect=projectionCanvas.getBoundingClientRect(),x=point[0]*rect.width/1200,y=point[1]*rect.height/600; @@ -35,7 +72,7 @@ function scheduleMapThumbnails(){ mapThumbLayout=[];$('map-thumbnail-layer').replaceChildren(); const size=Number($('map-photo-size').value),density=Number($('map-photo-density').value),overlap=Number($('map-photo-overlap').value); $('map-size-value').value=size+' px';$('map-density-value').value=density+' / MP';$('map-overlap-value').value=overlap+'%'; - if(!$('map-photos').checked){$('map-photo-status').textContent='Enable map thumbnails to interpret numeric class IDs as GBIF taxa. Images stay at their projected positions.';return;} + if(!$('map-photos').checked){$('map-photo-status').textContent='Enable map thumbnails to interpret numeric class IDs as GBIF taxa. Push apart connects displaced images to their fixed prototype points.';return;} if(data.metadata.synthetic){$('map-photo-status').textContent='Synthetic case: no GBIF photo requests.';return;} $('map-photo-status').textContent='Updating visible thumbnails…'; mapThumbTimer=setTimeout(renderMapThumbnails,180); @@ -44,15 +81,15 @@ async function renderMapThumbnails(){ const generation=mapThumbGeneration,caseData=data; mapThumbController=new AbortController();const signal=mapThumbController.signal; const rect=projectionCanvas.getBoundingClientRect(),size=Number($('map-photo-size').value); - const config={width:rect.width,height:rect.height,size,density:Number($('map-photo-density').value),overlap:Number($('map-photo-overlap').value)/100}; + const config={width:rect.width,height:rect.height,size,density:Number($('map-photo-density').value),overlap:Number($('map-photo-overlap').value)/100,labels:$('map-photo-labels').checked,push:$('map-photo-push').checked}; const points=projectionHits.map(([x,y])=>[x*rect.width/1200,y*rect.height/600]); // Selected class first, then its true neighbours; other classes have stable // mixed row order so dense regions do not win simply by checkpoint ordering. const remaining=data.names.map((_,i)=>i).sort((a,b)=>(Math.imul(a,2654435761)>>>0)-(Math.imul(b,2654435761)>>>0)); const priority=[selected,...data.neighbours[selected],...remaining].filter(i=>photoNumber(data.names[i])&&!mapThumbFailures.has(data.names[i])); - const {boxes,budget}=thumbnailLayout(points,priority,config); + const {boxes,budget,culled}=thumbnailLayout(points,priority,config); let loaded=0,unavailable=0; - const status=()=>{$('map-photo-status').textContent=`${loaded} images loaded · ${boxes.length} visible slots · budget ${budget} for this viewport. Maximum pairwise rectangle overlap ${Math.round(config.overlap*100)}%. ${unavailable} unavailable. Hover for credits; click to inspect. Colours/positions and neighbour ranks are unchanged.`;}; + const status=()=>{$('map-photo-status').textContent=`${loaded} images loaded · ${boxes.length} visible slots · budget ${budget} for this viewport. ${config.push?`Push apart: overlap admits candidates; displayed rectangles do not overlap. Images move at most ${2*size} px; ${culled} unresolved collisions culled.`:`Maximum pairwise rectangle overlap ${Math.round(config.overlap*100)}%.`} ${unavailable} unavailable. Hover for names and credits; click to inspect. Prototype coordinates and neighbour ranks stay fixed.`;}; if(!boxes.length){status();return;} try{const health=await fetch('/api/health',{signal});if(!health.ok||!(await health.json()).photo_api)throw Error('Photo server unavailable');} catch(error){if(!signal.aborted)$('map-photo-status').textContent='Map photos require the local GBIF photo server. Numerical views remain available.';return;} @@ -67,20 +104,23 @@ async function renderMapThumbnails(){ const choice=(photoChoices.get(id)||0)%album.photos.length,photo=album.photos[choice]; let image=mapThumbImages.get(photo.image_path)?.cloneNode(); const button=photoText('button','','map-thumb');button.type='button';button.style.left=box.x+'px';button.style.top=box.y+'px';button.style.width=box.width+'px';button.style.height=box.height+'px';button.style.zIndex=String(boxes.length-boxes.indexOf(box));button.style.borderColor=box.id===selected?'#c2344b':data.neighbours[selected].includes(box.id)?'#087e8b':'#7c90a3';button.setAttribute('aria-label',`Inspect ${album.display_name}, class ${id}`);button.onclick=()=>selectClass(box.id);button.onfocus=()=>showMapPhotoCredit(box.id); + button.classList.toggle('image-only',!config.labels);button.title=`${album.display_name} · class ${id}`; if(!image){image=new Image();image.decoding='async';} image.draggable=false;image.alt=album.display_name;image.style.height=size+'px'; - button.append(image,photoText('span',id));$('map-thumbnail-layer').append(button); + button.append(image);if(config.labels)button.append(photoText('span',id)); + const tether=thumbnailTether(box);if(tether)$('map-thumbnail-layer').append(tether); + $('map-thumbnail-layer').append(button); mapThumbLayout.push(box); // Placeholders have the same culled footprint. const okay=(image.complete&&image.naturalWidth>0)||await loadReferenceImage(image,photo.image_path,signal); if(signal.aborted||generation!==mapThumbGeneration)return; if(okay){loaded++;mapThumbImages.delete(photo.image_path);mapThumbImages.set(photo.image_path,image);while(mapThumbImages.size>128)mapThumbImages.delete(mapThumbImages.keys().next().value);} - else{button.remove();mapThumbLayout=mapThumbLayout.filter(b=>b!==box);mapThumbFailures.add(id);unavailable++;} + else{button.remove();tether?.remove();mapThumbLayout=mapThumbLayout.filter(b=>b!==box);mapThumbFailures.add(id);unavailable++;} status();if(box.id===selected)showMapPhotoCredit(selected); }catch(error){if(signal.aborted)return;mapThumbFailures.add(id);unavailable++;status();} } if(generation===mapThumbGeneration){status();showMapPhotoCredit(selected);} } -for(const name of ['map-photos','map-photo-size','map-photo-density','map-photo-overlap'])$(name).oninput=drawProjection; +for(const name of ['map-photos','map-photo-size','map-photo-density','map-photo-overlap','map-photo-labels','map-photo-push'])$(name).oninput=drawProjection; $('map-photo-retry').onclick=()=>{mapThumbFailures.clear();scheduleMapThumbnails();}; new ResizeObserver(scheduleMapThumbnails).observe(projectionCanvas); scheduleMapThumbnails(); diff --git a/docs/hyperspherical-visualization.md b/docs/hyperspherical-visualization.md index c0aac22e..dbe4cecd 100644 --- a/docs/hyperspherical-visualization.md +++ b/docs/hyperspherical-visualization.md @@ -399,9 +399,11 @@ initialization and specialized metric handling. Async fitting is not by itself a worker-thread guarantee. Initially, fit in Python and serve coordinates; use a worker if browser-side fitting later becomes useful. -For every renderer, a thumbnail is a class image label anchored to a numerical +For every renderer, a thumbnail is a class image label associated with a numerical point. Culling, visual size and overlap may change label visibility but must not -move prototypes or change their distances. A globe adds back-side occlusion to +move prototypes or change their distances. Optional label displacement should +retain an explicit connection to the fixed prototype point; the explorer's +**Push thumbnails apart** mode uses anchor markers and leader lines. A globe adds back-side occlusion to the existing viewport and overlap tests. Preserve photo provenance and cache budgets, and expose point counts even where image labels are culled. From e131a9d9b6f381fcdfcb9b17b83958de4be03e7e Mon Sep 17 00:00:00 2001 From: asgersvenning Date: Thu, 10 Sep 2026 23:44:50 +0200 Subject: [PATCH 10/34] feat: animate thumbnail forces and streamline photo loading --- dev/prototype_space/README.md | 20 +++-- dev/prototype_space/check_browser.mjs | 24 ++++- dev/prototype_space/report.html | 4 +- dev/prototype_space/serve.py | 15 +++- dev/prototype_space/thumbnails.js | 123 +++++++++++++++++++------- tests/utils/test_prototype_photos.py | 24 +++++ 6 files changed, 165 insertions(+), 45 deletions(-) diff --git a/dev/prototype_space/README.md b/dev/prototype_space/README.md index 9a5728aa..1074927a 100644 --- a/dev/prototype_space/README.md +++ b/dev/prototype_space/README.md @@ -120,16 +120,22 @@ as needed. Names, IDs and credits remain available on hover or keyboard focus. Images initially stay centered on actual projected points. Enable **Push thumbnails apart** to admit candidates using the overlap setting, then separate their -rectangles with bounded pairwise repulsion. Images may move up to twice their -configured size in screen pixels. Anchor markers and leader lines identify the +rectangles with animated soft repulsion, spring attraction toward their anchors, +and damped velocity. Images may move up to twice their configured size in screen pixels. Anchor markers and leader lines identify the fixed prototype positions. Unresolved collisions are culled in priority order, -so displayed rectangles do not overlap. The status reports the remaining slots -and collision culls. Increase allowable overlap to try a denser candidate set; +so settled rectangles do not overlap. Temporary overlap is visible during +animation. The simulation cools over about 2.7 seconds after the last arrival, +then stops requesting frames; reduced-motion preferences settle immediately. +The status reports the remaining visible slots. Increase allowable overlap to try +a denser candidate set; this does not guarantee that every candidate will fit after separation. Culling prioritizes the selected class and its original-space neighbours, then uses a stable mixed class order. -Only fully visible, admitted rectangles trigger image requests. Pan/zoom cancels +Only fully visible, admitted rectangles trigger image requests. Up to four images +load concurrently. Each decoded image fades in at its anchor before it joins the +force simulation; unloaded images neither appear as blank cards nor exert forces. +Pan/zoom cancels stale loads, hides stale placements immediately, and recomputes after a short pause; no images are fetched while dragging continuously. Cached metadata is shared with the class cards, and changing a class example also refreshes map @@ -154,7 +160,9 @@ reports a different accepted taxon ID. Only the visible neighbourhood is requested. Metadata and thumbnails are cached in a sibling `prototype-report-gbif-cache` directory; upstream requests are -serialized. Synthetic cases never trigger lookups. Missing images or network +serialized, while cached images and already-resolved class metadata bypass that +queue. The browser can therefore load cached images while an upstream request +is still pending. Synthetic cases never trigger lookups. Missing images or network access leave the numerical views usable. Media credits and licenses are shown when supplied; occurrence-data licenses are never substituted for image licenses. The service uses the [GBIF image API](https://techdocs.gbif.org/en/openapi/images). diff --git a/dev/prototype_space/check_browser.mjs b/dev/prototype_space/check_browser.mjs index ed549cc6..bc219964 100644 --- a/dev/prototype_space/check_browser.mjs +++ b/dev/prototype_space/check_browser.mjs @@ -12,7 +12,7 @@ const chrome=spawn(executable,['--headless','--no-sandbox','--disable-gpu','--di let sequence=0,buffer=Buffer.alloc(0);const pending=new Map();const errors=[]; chrome.stdio[4].on('data',chunk=>{buffer=Buffer.concat([buffer,chunk]);let end;while((end=buffer.indexOf(0))!==-1){const msg=JSON.parse(buffer.subarray(0,end));buffer=buffer.subarray(end+1);if(msg.id){const cb=pending.get(msg.id);if(cb){pending.delete(msg.id);msg.error?cb.reject(Error(JSON.stringify(msg.error))):cb.resolve(msg.result);}}else if(msg.method==='Runtime.exceptionThrown')errors.push(msg.params.exceptionDetails);}}); function send(method,params={},sessionId){const id=++sequence;return new Promise((resolve,reject)=>{pending.set(id,{resolve,reject});chrome.stdio[3].write(JSON.stringify({id,method,params,...(sessionId?{sessionId}:{})})+'\0');});} -const deadline=setTimeout(()=>{console.error('Browser smoke timed out');chrome.kill('SIGTERM');process.exitCode=1;},45000); +const deadline=setTimeout(()=>{console.error('Browser smoke timed out');chrome.kill('SIGTERM');process.exitCode=1;},60000); try{ const {targetId}=await send('Target.createTarget',{url:'about:blank'});const {sessionId}=await send('Target.attachToTarget',{targetId,flatten:true}); const call=(m,p)=>send(m,p,sessionId);await call('Page.enable');await call('Runtime.enable');await call('Emulation.setDeviceMetricsOverride',{width:1440,height:1100,deviceScaleFactor:1,mobile:false}); @@ -108,7 +108,27 @@ try{ check(JSON.stringify(projectionHits)===coordinatesBefore,'thumbnail relaxation preserves projected coordinates'); const displaced=mapThumbLayout.find(b=>Math.hypot(b.x-b.anchorX,b.y-b.anchorY)>=.5); check(displaced&&document.querySelectorAll('.map-thumb-tether').length>0,'displaced photos retain visible anchor markers'); - check(mapThumbnailHit([displaced.x*1200/projectionCanvas.getBoundingClientRect().width,displaced.y*600/projectionCanvas.getBoundingClientRect().height])===displaced.id,'displaced thumbnail hit testing'); + const motionConfig={width:projectionCanvas.getBoundingClientRect().width,height:projectionCanvas.getBoundingClientRect().height,size:64}; + for(const b of mapThumbLayout){b.x=b.anchorX;b.y=b.anchorY;b.left=b.x-b.width/2;b.top=b.y-b.height/2;b.vx=0;b.vy=0;} + const startPositions=mapThumbLayout.map(b=>[b.x,b.y]);const moving=animateThumbnails(motionConfig); + await new Promise(resolve=>setTimeout(resolve,120)); + check(mapThumbMotion!==null&&mapThumbLayout.some((b,i)=>Math.hypot(b.x-startPositions[i][0],b.y-startPositions[i][1])>.1),'force layout advances visibly over animation frames'); + check(mapThumbLayout.every(b=>Math.abs(parseFloat(b.button.style.left)-b.x)<.01&&Math.abs(parseFloat(b.button.style.top)-b.y)<.01),'animated images and hit rectangles move together'); + await moving;check(mapThumbMotion===null&&separate(mapThumbLayout),'force simulation cools and stops without residual overlaps'); + const originalMatchMedia=window.matchMedia;window.matchMedia=()=>({matches:true});await animateThumbnails(motionConfig);window.matchMedia=originalMatchMedia; + check(mapThumbMotion===null&&separate(mapThumbLayout),'reduced motion settles without scheduling animation'); + const cancelled=animateThumbnails(motionConfig);stopThumbnailMotion();await cancelled; + check(mapThumbMotion===null,'force animation cancellation resolves pending work'); + document.getElementById('map-photo-density').value='5';mapThumbImages.clear(); + const originalLoad=loadReferenceImage;let activeImages=0,maxImages=0; + loadReferenceImage=async(...args)=>{activeImages++;maxImages=Math.max(maxImages,activeImages);await new Promise(resolve=>setTimeout(resolve,100));const result=await originalLoad(...args);activeImages--;return result;}; + scheduleMapThumbnails();clearTimeout(mapThumbTimer);const delayed=renderMapThumbnails();await new Promise(resolve=>setTimeout(resolve,40)); + check(activeImages>1&&activeImages<=4&&mapThumbLayout.length===0&&mapThumbMotion===null,'bounded concurrent image loads do not move unloaded thumbnails'); + await delayed;loadReferenceImage=originalLoad; + check(maxImages<=4&&mapThumbLayout.length>0&&mapThumbLayout.every(b=>b.button.querySelector('img').naturalWidth>0),'only visible decoded images enter force layout'); + + + const currentBox=mapThumbLayout[0];check(mapThumbnailHit([currentBox.x*1200/projectionCanvas.getBoundingClientRect().width,currentBox.y*600/projectionCanvas.getBoundingClientRect().height])===currentBox.id,'displaced thumbnail hit testing'); document.getElementById('map-photo-labels').checked=true;document.getElementById('map-photo-push').checked=false;document.getElementById('map-photo-overlap').value='0';document.getElementById('map-photo-density').value='40'; scheduleMapThumbnails();clearTimeout(mapThumbTimer);await renderMapThumbnails(); check([...document.querySelectorAll('.map-thumb')].every(b=>b.querySelector('span'))&&document.querySelectorAll('.map-thumb-tether').length===0,'label and fixed-position modes restore'); diff --git a/dev/prototype_space/report.html b/dev/prototype_space/report.html index c68a4ff3..6bc31d17 100644 --- a/dev/prototype_space/report.html +++ b/dev/prototype_space/report.html @@ -16,7 +16,9 @@ table{border-collapse:collapse;width:100%;font-size:12px}th,td{text-align:left;border-bottom:1px solid var(--line);padding:7px 5px}th{color:var(--muted)}td button{border:0;background:transparent;color:#087781;padding:0;font-size:12px;text-decoration:underline}.mono{font-family:ui-monospace,monospace}.notice{border-left:3px solid #c28936;padding:10px 14px;background:#fff8ec;font-size:13px}details{margin-top:14px}summary{cursor:pointer;font-weight:600}pre{font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere}.panel p{margin-top:0}#search-status{font-size:12px;color:var(--muted)}#cell-info{min-height:44px}.selected-title{font-size:24px}.case-note{margin-bottom:18px}.tree-hit{cursor:pointer}footer{font-size:13px;color:var(--muted);padding-top:10px} @media(max-width:850px){.grid{grid-template-columns:1fr}.stats{grid-template-columns:1fr 1fr}.controls{position:static}main{padding:16px 12px}.panel{padding:17px}header{padding:24px}.controls input{width:160px}} .photo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:14px}.photo-card{border:1px solid var(--line);border-radius:8px;padding:10px;min-width:0}.photo-selected{border:2px solid var(--accent)}.photo-open{width:100%;padding:0;border:0;background:#edf2f5;height:150px;overflow:hidden}.photo-open img{display:block;width:100%;height:100%;object-fit:contain}.photo-title{display:block;margin-top:8px}.photo-credit{font-size:10px;color:var(--muted);overflow-wrap:anywhere;margin:8px 0 0!important}.photo-credit a{color:var(--accent)}.photo-card button:not(.photo-open){font-size:11px;padding:4px 6px} -.map-thumb{position:absolute;transform:translate(-50%,-50%);padding:2px;border:2px solid #087e8b;border-radius:4px;background:white;pointer-events:none;overflow:hidden;box-shadow:0 1px 4px #0003}.map-thumb img{display:block;width:100%;object-fit:contain;background:#f5f7fa}.map-thumb span{display:block;font-size:10px;height:16px;line-height:16px;white-space:nowrap;overflow:hidden}.map-thumb.image-only{padding:0;border:0;border-radius:0;box-shadow:none}.map-thumb.image-only img{object-fit:cover;background:transparent}.map-thumb.image-only:focus-visible{outline-offset:-3px}.map-thumb-tether{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;overflow:visible}.map-thumb:focus-visible{outline:3px solid #ffae3d} +@keyframes thumb-appear{from{opacity:0}to{opacity:1}} +.map-thumb{animation:thumb-appear .18s ease-out;position:absolute;transform:translate(-50%,-50%);padding:2px;border:2px solid #087e8b;border-radius:4px;background:white;pointer-events:none;overflow:hidden;box-shadow:0 1px 4px #0003}.map-thumb img{display:block;width:100%;object-fit:contain;background:#f5f7fa}.map-thumb span{display:block;font-size:10px;height:16px;line-height:16px;white-space:nowrap;overflow:hidden}.map-thumb.image-only{padding:0;border:0;border-radius:0;box-shadow:none}.map-thumb.image-only img{object-fit:cover;background:transparent}.map-thumb.image-only:focus-visible{outline-offset:-3px}.map-thumb-tether{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;overflow:visible}.map-thumb:focus-visible{outline:3px solid #ffae3d} +@media(prefers-reduced-motion:reduce){.map-thumb{animation:none}}
mini_trainer · empirical exploration

Prototype observatory

Move between the global tree, pairwise structure and a class’s local neighbourhood. The existing mini_trainer distance is the reference; the pre-CDF z-score exposes detail that can be hidden by probability rounding.

diff --git a/dev/prototype_space/serve.py b/dev/prototype_space/serve.py index 2c4eb90b..88406363 100644 --- a/dev/prototype_space/serve.py +++ b/dev/prototype_space/serve.py @@ -85,6 +85,7 @@ def __init__(self, allowed_ids, cache): self.allowed_ids = set(allowed_ids) self.cache = cache self.image_paths = set() + self.metadata_cache = {} # GBIF asks scripted image-cache users to use a single HTTP connection. # Serialize all upstream requests, including image proxy requests. self.lock = threading.Lock() @@ -92,18 +93,26 @@ def __init__(self, allowed_ids, cache): def metadata(self, class_id): if class_id not in self.allowed_ids or not class_id.isdigit(): raise ValueError("Class is not a GBIF-ID candidate in this report") + if class_id in self.metadata_cache: + return self.metadata_cache[class_id] with self.lock: + if class_id in self.metadata_cache: + return self.metadata_cache[class_id] taxon = gbif.retrive_request(f"{gbif.GBIF_SPECIES_API_ENDPOINT}{class_id}") result = gbif.retrive_request(f"https://api.gbif.org/v1/occurrence/search?taxonKey={class_id}&mediaType=StillImage&limit=20") data = reference_photos(class_id, taxon, result.get("results", [])) self.image_paths.update(photo["image_path"] for photo in data["photos"]) + self.metadata_cache[class_id] = data return data def image(self, path): + if path not in self.image_paths: + raise ValueError("Image has not been resolved for a class in this report") + cache_key = "prototype-image:" + path + cached = self.cache.get(cache_key) + if cached is not None: + return cached with self.lock: - if path not in self.image_paths: - raise ValueError("Image has not been resolved for a class in this report") - cache_key = "prototype-image:" + path cached = self.cache.get(cache_key) if cached is not None: return cached diff --git a/dev/prototype_space/thumbnails.js b/dev/prototype_space/thumbnails.js index ff0ee83e..530d5cac 100644 --- a/dev/prototype_space/thumbnails.js +++ b/dev/prototype_space/thumbnails.js @@ -6,31 +6,78 @@ const mapThumbFailures = new Set(); function thumbnailSharedArea(a,b){ return Math.max(0,Math.min(a.left+a.width,b.left+b.width)-Math.max(a.left,b.left))*Math.max(0,Math.min(a.top+a.height,b.top+b.height)-Math.max(a.top,b.top)); } -function relaxThumbnails(boxes,width,height,maxShift){ - const move=(box,dx,dy)=>{ - let x=Math.max(box.width/2,Math.min(width-box.width/2,box.x+dx)); - let y=Math.max(box.height/2,Math.min(height-box.height/2,box.y+dy)); - const distance=Math.hypot(x-box.anchorX,y-box.anchorY); - if(distance>maxShift){x=box.anchorX+(x-box.anchorX)*maxShift/distance;y=box.anchorY+(y-box.anchorY)*maxShift/distance;} - box.x=x;box.y=y;box.left=x-box.width/2;box.top=y-box.height/2; - }; - // Bounded deterministic pairwise repulsion. Any unresolved collisions are - // culled in original priority order, rather than silently retaining overlap. - for(let pass=0;pass<48;pass++){ - let collisions=0; - for(let i=0;i[(b.anchorX-b.x)*.014*heat,(b.anchorY-b.y)*.014*heat]); + for(let i=0;i=radius)continue; + const force=(radius-d)*.16; + forces[i][0]-=nx*force;forces[i][1]-=ny*force;forces[j][0]+=nx*force;forces[j][1]+=ny*force; } + let speed=0; + boxes.forEach((b,i)=>{ + b.vx=((b.vx||0)+forces[i][0])*.72;b.vy=((b.vy||0)+forces[i][1])*.72; + const velocity=Math.hypot(b.vx,b.vy);if(velocity>8){b.vx*=8/velocity;b.vy*=8/velocity;} + let x=Math.max(b.width/2,Math.min(width-b.width/2,b.x+b.vx)),y=Math.max(b.height/2,Math.min(height-b.height/2,b.y+b.vy)); + const distance=Math.hypot(x-b.anchorX,y-b.anchorY); + if(distance>maxShift){x=b.anchorX+(x-b.anchorX)*maxShift/distance;y=b.anchorY+(y-b.anchorY)*maxShift/distance;} + speed=Math.max(speed,Math.hypot(x-b.x,y-b.y)); + b.vx=x-b.x;b.vy=y-b.y;b.x=x;b.y=y;b.left=x-b.width/2;b.top=y-b.height/2; + }); + return speed; +} +function cullThumbnailCollisions(boxes){ const kept=[]; for(const box of boxes)if(!kept.some(other=>thumbnailSharedArea(box,other)>1e-8))kept.push(box); return kept; } +function relaxThumbnails(boxes,width,height,maxShift){ + for(let step=0;step<160;step++)stepThumbnailForce(boxes,width,height,maxShift,Math.max(0,1-step/120)); + return cullThumbnailCollisions(boxes); +} +let mapThumbMotion=null; +function stopThumbnailMotion(){ + if(!mapThumbMotion)return; + cancelAnimationFrame(mapThumbMotion.frame);mapThumbMotion.resolve();mapThumbMotion=null; +} +function paintThumbnailMotion(){ + for(const box of mapThumbLayout){ + box.button.style.left=box.x+'px';box.button.style.top=box.y+'px'; + const line=box.tether.querySelector('line'); + line.setAttribute('x2',Math.max(box.left,Math.min(box.left+box.width,box.anchorX))); + line.setAttribute('y2',Math.max(box.top,Math.min(box.top+box.height,box.anchorY))); + box.tether.style.visibility=Math.hypot(box.x-box.anchorX,box.y-box.anchorY)<.5?'hidden':'visible'; + } +} +function animateThumbnails(config){ + stopThumbnailMotion(); + return new Promise(resolve=>{ + const motion={frame:0,step:0,resolve,last:0};mapThumbMotion=motion; + const finish=()=>{ + const kept=cullThumbnailCollisions(mapThumbLayout),keep=new Set(kept); + for(const box of mapThumbLayout)if(!keep.has(box)){box.button.remove();box.tether.remove();} + mapThumbLayout=kept;paintThumbnailMotion();mapThumbMotion=null;resolve(); + }; + const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches; + const advance=()=>{stepThumbnailForce(mapThumbLayout.filter(b=>reduced||!b.readyAt||performance.now()>=b.readyAt),config.width,config.height,2*config.size,Math.max(0,1-motion.step/120));motion.step++;}; + if(reduced){while(motion.step<160)advance();finish();return;} + const tick=time=>{ + if(mapThumbMotion!==motion)return; + // Fixed 60 Hz simulation, at most two steps per frame; no catch-up burst + // when a background tab resumes. Work stops entirely once the system cools. + if(!motion.last||time-motion.last>=15){advance();if(motion.last&&time-motion.last>=32&&motion.step<160)advance();motion.last=time;paintThumbnailMotion();} + if(motion.step>=160){finish();return;} + motion.frame=requestAnimationFrame(tick); + }; + motion.frame=requestAnimationFrame(tick); + }); +} function thumbnailLayout(points, priority, {width,height,size,density,overlap,labels=true,push=false}) { const budget=Math.min(200,Math.max(1,Math.floor(width*height*density/1e6))); const chosen=[],used=new Set();const boxWidth=labels?size+8:size,boxHeight=labels?size+24:size; @@ -45,8 +92,8 @@ function thumbnailLayout(points, priority, {width,height,size,density,overlap,la const boxes=push?relaxThumbnails(chosen,width,height,2*size):chosen; return {boxes,budget,culled:chosen.length-boxes.length}; } -function thumbnailTether(box){ - if(Math.hypot(box.x-box.anchorX,box.y-box.anchorY)<.5)return null; +function thumbnailTether(box,always=false){ + if(!always&&Math.hypot(box.x-box.anchorX,box.y-box.anchorY)<.5)return null; const ns='http://www.w3.org/2000/svg',svg=document.createElementNS(ns,'svg'); svg.classList.add('map-thumb-tether');svg.setAttribute('aria-hidden','true');svg.style.zIndex='201'; const line=document.createElementNS(ns,'line'); @@ -68,7 +115,7 @@ function showMapPhotoCredit(index){ node.append(photoText('span',`${album.display_name} · class ${id} · ${photo.creator} · ${photo.license} · `),photoLink('Image source',photo.source),photoText('span',' · '),photoLink('GBIF record','https://www.gbif.org/occurrence/'+photo.occurrence_id)); } function scheduleMapThumbnails(){ - clearTimeout(mapThumbTimer);mapThumbController?.abort();++mapThumbGeneration; + clearTimeout(mapThumbTimer);stopThumbnailMotion();mapThumbController?.abort();++mapThumbGeneration; mapThumbLayout=[];$('map-thumbnail-layer').replaceChildren(); const size=Number($('map-photo-size').value),density=Number($('map-photo-density').value),overlap=Number($('map-photo-overlap').value); $('map-size-value').value=size+' px';$('map-density-value').value=density+' / MP';$('map-overlap-value').value=overlap+'%'; @@ -87,20 +134,21 @@ async function renderMapThumbnails(){ // mixed row order so dense regions do not win simply by checkpoint ordering. const remaining=data.names.map((_,i)=>i).sort((a,b)=>(Math.imul(a,2654435761)>>>0)-(Math.imul(b,2654435761)>>>0)); const priority=[selected,...data.neighbours[selected],...remaining].filter(i=>photoNumber(data.names[i])&&!mapThumbFailures.has(data.names[i])); - const {boxes,budget,culled}=thumbnailLayout(points,priority,config); + const {boxes,budget}=thumbnailLayout(points,priority,{...config,push:false}); + let motionFinished=false; let loaded=0,unavailable=0; - const status=()=>{$('map-photo-status').textContent=`${loaded} images loaded · ${boxes.length} visible slots · budget ${budget} for this viewport. ${config.push?`Push apart: overlap admits candidates; displayed rectangles do not overlap. Images move at most ${2*size} px; ${culled} unresolved collisions culled.`:`Maximum pairwise rectangle overlap ${Math.round(config.overlap*100)}%.`} ${unavailable} unavailable. Hover for names and credits; click to inspect. Prototype coordinates and neighbour ranks stay fixed.`;}; + const status=()=>{$('map-photo-status').textContent=`${loaded} images loaded · ${motionFinished?mapThumbLayout.length:boxes.length} visible slots · budget ${budget} for this viewport. ${config.push?`Push apart: ${motionFinished?'settled; displayed rectangles do not overlap':'spring attraction and soft repulsion; temporary overlap while moving'}. Images move at most ${2*size} px.`:`Maximum pairwise rectangle overlap ${Math.round(config.overlap*100)}%.`} ${unavailable} unavailable. Hover for names and credits; click to inspect. Prototype coordinates and neighbour ranks stay fixed.`;}; if(!boxes.length){status();return;} try{const health=await fetch('/api/health',{signal});if(!health.ok||!(await health.json()).photo_api)throw Error('Photo server unavailable');} catch(error){if(!signal.aborted)$('map-photo-status').textContent='Map photos require the local GBIF photo server. Numerical views remain available.';return;} - for(const box of boxes){ + const loadBox=async box=>{ if(signal.aborted||generation!==mapThumbGeneration||data!==caseData)return; const id=data.names[box.id]; try{ let album=photoAlbums.get(id); if(!album){const response=await fetch('/api/gbif/'+id,{signal});album=await response.json();if(!response.ok)throw Error(album.error||'Photo lookup failed');photoAlbums.set(id,album);} if(signal.aborted||generation!==mapThumbGeneration)return; - if(!album.photos.length){mapThumbFailures.add(id);unavailable++;status();continue;} + if(!album.photos.length){mapThumbFailures.add(id);unavailable++;status();return;} const choice=(photoChoices.get(id)||0)%album.photos.length,photo=album.photos[choice]; let image=mapThumbImages.get(photo.image_path)?.cloneNode(); const button=photoText('button','','map-thumb');button.type='button';button.style.left=box.x+'px';button.style.top=box.y+'px';button.style.width=box.width+'px';button.style.height=box.height+'px';button.style.zIndex=String(boxes.length-boxes.indexOf(box));button.style.borderColor=box.id===selected?'#c2344b':data.neighbours[selected].includes(box.id)?'#087e8b':'#7c90a3';button.setAttribute('aria-label',`Inspect ${album.display_name}, class ${id}`);button.onclick=()=>selectClass(box.id);button.onfocus=()=>showMapPhotoCredit(box.id); @@ -108,17 +156,26 @@ async function renderMapThumbnails(){ if(!image){image=new Image();image.decoding='async';} image.draggable=false;image.alt=album.display_name;image.style.height=size+'px'; button.append(image);if(config.labels)button.append(photoText('span',id)); - const tether=thumbnailTether(box);if(tether)$('map-thumbnail-layer').append(tether); - $('map-thumbnail-layer').append(button); - mapThumbLayout.push(box); // Placeholders have the same culled footprint. + const tether=thumbnailTether(box,config.push); const okay=(image.complete&&image.naturalWidth>0)||await loadReferenceImage(image,photo.image_path,signal); if(signal.aborted||generation!==mapThumbGeneration)return; - if(okay){loaded++;mapThumbImages.delete(photo.image_path);mapThumbImages.set(photo.image_path,image);while(mapThumbImages.size>128)mapThumbImages.delete(mapThumbImages.keys().next().value);} + if(okay){ + box.button=button;box.tether=tether;box.readyAt=performance.now()+80; + if(tether)$('map-thumbnail-layer').append(tether);$('map-thumbnail-layer').append(button); + mapThumbLayout.push(box);mapThumbLayout.sort((a,b)=>boxes.indexOf(a)-boxes.indexOf(b)); + if(config.push)animateThumbnails(config); + loaded++;mapThumbImages.delete(photo.image_path);mapThumbImages.set(photo.image_path,image);while(mapThumbImages.size>128)mapThumbImages.delete(mapThumbImages.keys().next().value);} else{button.remove();tether?.remove();mapThumbLayout=mapThumbLayout.filter(b=>b!==box);mapThumbFailures.add(id);unavailable++;} status();if(box.id===selected)showMapPhotoCredit(selected); }catch(error){if(signal.aborted)return;mapThumbFailures.add(id);unavailable++;status();} - } - if(generation===mapThumbGeneration){status();showMapPhotoCredit(selected);} + }; + // Bound in-flight work; a slow image no longer stalls every later class. + let next=0; + await Promise.all(Array.from({length:Math.min(4,boxes.length)},async()=>{ + while(next{mapThumbFailures.clear();scheduleMapThumbnails();}; diff --git a/tests/utils/test_prototype_photos.py b/tests/utils/test_prototype_photos.py index a162966f..a35899dc 100644 --- a/tests/utils/test_prototype_photos.py +++ b/tests/utils/test_prototype_photos.py @@ -1,6 +1,7 @@ """Photo labels preserve class identity and media provenance.""" import hashlib +import threading import pytest @@ -49,3 +50,26 @@ def retrieve(url): service.cache["prototype-image:" + path] = (b"cached image", "image/jpeg") assert service.image(path) == (b"cached image", "image/jpeg") assert len(calls) == 2 + + +def test_cached_photos_bypass_busy_upstream_connection(monkeypatch): + monkeypatch.setattr(serve.gbif, "retrive_request", lambda url: {"results": []}) + service = serve.PhotoService({"42"}, {}) + album = service.metadata("42") + path = "/gbif-image/9/" + "a" * 32 + service.image_paths.add(path) + service.cache["prototype-image:" + path] = (b"cached", "image/jpeg") + results = [] + done = threading.Event() + + def cached_requests(): + results.extend([service.metadata("42"), service.image(path)]) + done.set() + + with service.lock: + thread = threading.Thread(target=cached_requests) + thread.start() + bypassed = done.wait(timeout=1) + thread.join(timeout=2) + assert bypassed, "Cached responses must not wait behind an upstream download" + assert results == [album, (b"cached", "image/jpeg")] From 23bd1635e915d087e652a3c5a2638c1b5546f58c Mon Sep 17 00:00:00 2001 From: asgersvenning Date: Thu, 10 Sep 2026 23:52:34 +0200 Subject: [PATCH 11/34] fix: preserve thumbnail motion across viewport changes --- dev/prototype_space/README.md | 22 ++++++--- dev/prototype_space/check_browser.mjs | 25 +++++++++- dev/prototype_space/report.html | 4 +- dev/prototype_space/thumbnails.js | 67 +++++++++++++++++++++------ 4 files changed, 95 insertions(+), 23 deletions(-) diff --git a/dev/prototype_space/README.md b/dev/prototype_space/README.md index 1074927a..25469d52 100644 --- a/dev/prototype_space/README.md +++ b/dev/prototype_space/README.md @@ -120,9 +120,15 @@ as needed. Names, IDs and credits remain available on hover or keyboard focus. Images initially stay centered on actual projected points. Enable **Push thumbnails apart** to admit candidates using the overlap setting, then separate their -rectangles with animated soft repulsion, spring attraction toward their anchors, -and damped velocity. Images may move up to twice their configured size in screen pixels. Anchor markers and leader lines identify the -fixed prototype positions. Unresolved collisions are culled in priority order, +rectangles with animated contact repulsion, spring attraction toward their anchors, +and damped velocity. Contacts use the rendered CSS border boxes (including labels +when enabled), with a two-pixel clearance and softly blended corner normals. +Separated boxes outside that clearance do not repel. Sizes, clearance, offsets +and velocities are measured in screen pixels, independent of projection zoom. +Images may move up to twice their configured size in screen pixels. +**Show thumbnail anchors** toggles the anchor markers and leader lines without +reloading images or restarting the simulation. They are hidden by default. +Unresolved collisions are culled in priority order, so settled rectangles do not overlap. Temporary overlap is visible during animation. The simulation cools over about 2.7 seconds after the last arrival, then stops requesting frames; reduced-motion preferences settle immediately. @@ -135,9 +141,13 @@ class and its original-space neighbours, then uses a stable mixed class order. Only fully visible, admitted rectangles trigger image requests. Up to four images load concurrently. Each decoded image fades in at its anchor before it joins the force simulation; unloaded images neither appear as blank cards nor exert forces. -Pan/zoom cancels -stale loads, hides stale placements immediately, and recomputes after a short -pause; no images are fetched while dragging continuously. Cached metadata is +Pan/zoom cancels stale requests and pauses the simulation. Loaded thumbnails +follow their transformed anchors immediately, retaining their screen-pixel +offsets and DOM image nodes. After a short pause, the visible candidate set is +updated and collision adjustment resumes; surviving images are neither reloaded +nor faded in again. No new images are fetched while dragging continuously. +Changing image size, label mode, push mode, case or projection plane rebuilds the +layout because its footprints or coordinate meaning have changed. Cached metadata is shared with the class cards, and changing a class example also refreshes map thumbnails. Decoded image references are bounded to 128 entries; server caching remains shared. Missing photos leave the underlying point visible. Retry clears diff --git a/dev/prototype_space/check_browser.mjs b/dev/prototype_space/check_browser.mjs index bc219964..9128c7fe 100644 --- a/dev/prototype_space/check_browser.mjs +++ b/dev/prototype_space/check_browser.mjs @@ -79,6 +79,16 @@ try{ document.getElementById('photo-enabled').checked=false;await renderPhotos(); document.getElementById('case').value='Production checkpoint';document.getElementById('case').dispatchEvent(new Event('change')); const fixturePoints=[[60,60],[100,60],[260,100],[450,50]], config={width:400,height:200,size:64,density:200,overlap:0}; + const forcePair=(size,gap,dy=0)=>[{id:0,x:200,y:200,anchorX:200,anchorY:200,width:size,height:size},{id:1,x:200+gap,y:200+dy,anchorX:200+gap,anchorY:200+dy,width:size,height:size}]; + const distant=forcePair(64,70);stepThumbnailForce(distant,1000,800,128,0); + check(distant.every(b=>b.vx===0&&b.vy===0),'separate thumbnail boxes exert no repulsion beyond two-pixel clearance'); + const small=forcePair(32,50),large=forcePair(64,50);stepThumbnailForce(small,1000,800,128,0);stepThumbnailForce(large,1000,800,128,0); + check(small.every(b=>b.vx===0)&&large.some(b=>Math.abs(b.vx)>0),'contact uses thumbnail image size'); + const corners=forcePair(64,67,67);stepThumbnailForce(corners,1000,800,128,0); + check(corners.every(b=>b.vx===0&&b.vy===0),'diagonally separated rectangles do not repel as oversized ellipses'); + const translated=forcePair(64,50),base=forcePair(64,50);for(const b of translated){b.x+=100;b.anchorX+=100;b.y+=50;b.anchorY+=50;}stepThumbnailForce(base,1000,800,128,0);stepThumbnailForce(translated,1000,800,128,0); + check(base.every((b,i)=>Math.abs(b.vx-translated[i].vx)<1e-10&&Math.abs(b.vy-translated[i].vy)<1e-10),'screen-space forces are invariant to viewport translation'); + check(thumbnailLayout(fixturePoints,[0,1,2,3],config).boxes.map(b=>b.id).join(',')==='0,2','thumbnail viewport and overlap culling'); check(thumbnailLayout(fixturePoints,[0,1,2,3],{...config,overlap:.5}).boxes.length===3,'thumbnail allowable overlap'); check(thumbnailLayout(fixturePoints,[0,1,2,3],{...config,density:5}).boxes.length===1,'thumbnail density budget'); @@ -108,6 +118,19 @@ try{ check(JSON.stringify(projectionHits)===coordinatesBefore,'thumbnail relaxation preserves projected coordinates'); const displaced=mapThumbLayout.find(b=>Math.hypot(b.x-b.anchorX,b.y-b.anchorY)>=.5); check(displaced&&document.querySelectorAll('.map-thumb-tether').length>0,'displaced photos retain visible anchor markers'); + const anchorNodes=[...document.querySelectorAll('.map-thumb-tether')]; + check(anchorNodes.every(n=>getComputedStyle(n).display==='none'),'thumbnail anchors default to hidden'); + const layoutBeforeToggle=mapThumbLayout; + document.getElementById('map-photo-anchors').checked=true;document.getElementById('map-photo-anchors').dispatchEvent(new Event('input')); + check(anchorNodes.every(n=>getComputedStyle(n).display!=='none')&&mapThumbLayout===layoutBeforeToggle,'anchor toggle does not restart or reload thumbnails'); + document.getElementById('map-photo-anchors').checked=false;setThumbnailAnchors(); + const beforeView={...projectionView},beforeNodes=new Map(mapThumbLayout.map(b=>[b.id,{button:b.button,x:b.x,anchorX:b.anchorX,offset:b.x-b.anchorX,width:b.width}])); + projectionView.x+=2/projectionView.scale;projectionView.scale*=1.01;drawProjection();clearTimeout(mapThumbTimer); + check(mapThumbLayout.length>0&&mapThumbLayout.every(b=>b.button===beforeNodes.get(b.id).button&&Math.abs(b.x-b.anchorX-beforeNodes.get(b.id).offset)<1e-8&&b.width===beforeNodes.get(b.id).width),'pan and zoom preserve loaded DOM nodes, pixel offsets and image dimensions'); + const fetchBefore=window.fetch;let refreshRequests=0;window.fetch=async(...args)=>{refreshRequests++;return fetchBefore(...args);};await renderMapThumbnails();window.fetch=fetchBefore; + check(mapThumbLayout.some(b=>beforeNodes.get(b.id)?.button===b.button),'settled viewport refresh reuses surviving thumbnail nodes'); + projectionView=beforeView;drawProjection();clearTimeout(mapThumbTimer);await renderMapThumbnails(); + const motionConfig={width:projectionCanvas.getBoundingClientRect().width,height:projectionCanvas.getBoundingClientRect().height,size:64}; for(const b of mapThumbLayout){b.x=b.anchorX;b.y=b.anchorY;b.left=b.x-b.width/2;b.top=b.y-b.height/2;b.vx=0;b.vy=0;} const startPositions=mapThumbLayout.map(b=>[b.x,b.y]);const moving=animateThumbnails(motionConfig); @@ -119,7 +142,7 @@ try{ check(mapThumbMotion===null&&separate(mapThumbLayout),'reduced motion settles without scheduling animation'); const cancelled=animateThumbnails(motionConfig);stopThumbnailMotion();await cancelled; check(mapThumbMotion===null,'force animation cancellation resolves pending work'); - document.getElementById('map-photo-density').value='5';mapThumbImages.clear(); + document.getElementById('map-photos').checked=false;scheduleMapThumbnails();document.getElementById('map-photos').checked=true;document.getElementById('map-photo-density').value='5';mapThumbImages.clear(); const originalLoad=loadReferenceImage;let activeImages=0,maxImages=0; loadReferenceImage=async(...args)=>{activeImages++;maxImages=Math.max(maxImages,activeImages);await new Promise(resolve=>setTimeout(resolve,100));const result=await originalLoad(...args);activeImages--;return result;}; scheduleMapThumbnails();clearTimeout(mapThumbTimer);const delayed=renderMapThumbnails();await new Promise(resolve=>setTimeout(resolve,40)); diff --git a/dev/prototype_space/report.html b/dev/prototype_space/report.html index 6bc31d17..fe4c3f7e 100644 --- a/dev/prototype_space/report.html +++ b/dev/prototype_space/report.html @@ -17,7 +17,7 @@ @media(max-width:850px){.grid{grid-template-columns:1fr}.stats{grid-template-columns:1fr 1fr}.controls{position:static}main{padding:16px 12px}.panel{padding:17px}header{padding:24px}.controls input{width:160px}} .photo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:14px}.photo-card{border:1px solid var(--line);border-radius:8px;padding:10px;min-width:0}.photo-selected{border:2px solid var(--accent)}.photo-open{width:100%;padding:0;border:0;background:#edf2f5;height:150px;overflow:hidden}.photo-open img{display:block;width:100%;height:100%;object-fit:contain}.photo-title{display:block;margin-top:8px}.photo-credit{font-size:10px;color:var(--muted);overflow-wrap:anywhere;margin:8px 0 0!important}.photo-credit a{color:var(--accent)}.photo-card button:not(.photo-open){font-size:11px;padding:4px 6px} @keyframes thumb-appear{from{opacity:0}to{opacity:1}} -.map-thumb{animation:thumb-appear .18s ease-out;position:absolute;transform:translate(-50%,-50%);padding:2px;border:2px solid #087e8b;border-radius:4px;background:white;pointer-events:none;overflow:hidden;box-shadow:0 1px 4px #0003}.map-thumb img{display:block;width:100%;object-fit:contain;background:#f5f7fa}.map-thumb span{display:block;font-size:10px;height:16px;line-height:16px;white-space:nowrap;overflow:hidden}.map-thumb.image-only{padding:0;border:0;border-radius:0;box-shadow:none}.map-thumb.image-only img{object-fit:cover;background:transparent}.map-thumb.image-only:focus-visible{outline-offset:-3px}.map-thumb-tether{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;overflow:visible}.map-thumb:focus-visible{outline:3px solid #ffae3d} +.map-thumb{animation:thumb-appear .18s ease-out;position:absolute;transform:translate(-50%,-50%);padding:2px;border:2px solid #087e8b;border-radius:4px;background:white;pointer-events:none;overflow:hidden;box-shadow:0 1px 4px #0003}.map-thumb img{display:block;width:100%;object-fit:contain;background:#f5f7fa}.map-thumb span{display:block;font-size:10px;height:16px;line-height:16px;white-space:nowrap;overflow:hidden}.map-thumb.image-only{padding:0;border:0;border-radius:0;box-shadow:none}.map-thumb.image-only img{object-fit:cover;background:transparent}.map-thumb.image-only:focus-visible{outline-offset:-3px}.hide-thumb-anchors .map-thumb-tether{display:none}.map-thumb-tether{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;overflow:visible}.map-thumb:focus-visible{outline:3px solid #ffae3d} @media(prefers-reduced-motion:reduce){.map-thumb{animation:none}}
mini_trainer · empirical exploration

Prototype observatory

Move between the global tree, pairwise structure and a class’s local neighbourhood. The existing mini_trainer distance is the reference; the pre-CDF z-score exposes detail that can be hidden by probability rounding.

@@ -35,7 +35,7 @@

Prototype space · spatial projection

Compare linear PCA with angular t-SNE when available. Scroll to zoom, drag to pan, click a point or thumbnail to inspect its class. Each case is fitted separately.

-
+

Hover a point for its class ID.

Red: selected class · teal: its neighbours in the original space · orange rings: nearby in this plane only · grey: other classes. Links follow the original-space ranking. Projected gaps and apparent overlaps can differ from the full embedding geometry.

diff --git a/dev/prototype_space/thumbnails.js b/dev/prototype_space/thumbnails.js index 530d5cac..5bbff0e6 100644 --- a/dev/prototype_space/thumbnails.js +++ b/dev/prototype_space/thumbnails.js @@ -9,16 +9,17 @@ function thumbnailSharedArea(a,b){ function stepThumbnailForce(boxes,width,height,maxShift,heat){ const forces=boxes.map(b=>[(b.anchorX-b.x)*.014*heat,(b.anchorY-b.y)*.014*heat]); for(let i=0;i=radius)continue; - const force=(radius-d)*.16; - forces[i][0]-=nx*force;forces[i][1]-=ny*force;forces[j][0]+=nx*force;forces[j][1]+=ny*force; + const a=boxes[i],b=boxes[j],dx=b.x-a.x,dy=b.y-a.y; + // Contact energy uses the actual CSS border boxes with a two-pixel clearance. + // A soft minimum blends the two contact normals near corners, avoiding + // axis-snapping without repelling distant, nonintersecting rectangles. + const px=(a.width+b.width)/2+2-Math.abs(dx),py=(a.height+b.height)/2+2-Math.abs(dy); + if(px<=0||py<=0)continue; + const depth=Math.min(px,py),wx=Math.exp(-(px-depth)/4),wy=Math.exp(-(py-depth)/4); + const angle=((a.id*137.508+b.id*71.3)%360)*Math.PI/180; + const sx=dx===0?(Math.cos(angle)<0?-1:1):Math.sign(dx),sy=dy===0?(Math.sin(angle)<0?-1:1):Math.sign(dy); + const fx=sx*.24*depth*wx/(wx+wy),fy=sy*.24*depth*wy/(wx+wy); + forces[i][0]-=fx;forces[i][1]-=fy;forces[j][0]+=fx;forces[j][1]+=fy; } let speed=0; boxes.forEach((b,i)=>{ @@ -49,7 +50,10 @@ function stopThumbnailMotion(){ function paintThumbnailMotion(){ for(const box of mapThumbLayout){ box.button.style.left=box.x+'px';box.button.style.top=box.y+'px'; + if(!box.tether)continue; const line=box.tether.querySelector('line'); + line.setAttribute('x1',box.anchorX);line.setAttribute('y1',box.anchorY); + const dot=box.tether.querySelector('circle');dot.setAttribute('cx',box.anchorX);dot.setAttribute('cy',box.anchorY); line.setAttribute('x2',Math.max(box.left,Math.min(box.left+box.width,box.anchorX))); line.setAttribute('y2',Math.max(box.top,Math.min(box.top+box.height,box.anchorY))); box.tether.style.visibility=Math.hypot(box.x-box.anchorX,box.y-box.anchorY)<.5?'hidden':'visible'; @@ -114,10 +118,32 @@ function showMapPhotoCredit(index){ const photo=album.photos[(photoChoices.get(id)||0)%album.photos.length]; node.append(photoText('span',`${album.display_name} · class ${id} · ${photo.creator} · ${photo.license} · `),photoLink('Image source',photo.source),photoText('span',' · '),photoLink('GBIF record','https://www.gbif.org/occurrence/'+photo.occurrence_id)); } +let mapThumbCase=null,mapThumbKey='',mapThumbOffsets=new Map(); +function reanchorThumbnails(points,width,height){ + const kept=[]; + for(const box of mapThumbLayout){ + const point=points[box.id];if(!point)continue; + const dx=box.x-box.anchorX,dy=box.y-box.anchorY; + box.anchorX=point[0];box.anchorY=point[1];box.x=point[0]+dx;box.y=point[1]+dy; + box.left=box.x-box.width/2;box.top=box.y-box.height/2; + if(box.left<0||box.top<0||box.left+box.width>width||box.top+box.height>height){box.button.remove();box.tether?.remove();continue;} + kept.push(box); + } + mapThumbLayout=kept;paintThumbnailMotion(); +} +function setThumbnailAnchors(){ + $('map-thumbnail-layer').classList.toggle('hide-thumb-anchors',!$('map-photo-anchors').checked); +} function scheduleMapThumbnails(){ clearTimeout(mapThumbTimer);stopThumbnailMotion();mapThumbController?.abort();++mapThumbGeneration; - mapThumbLayout=[];$('map-thumbnail-layer').replaceChildren(); const size=Number($('map-photo-size').value),density=Number($('map-photo-density').value),overlap=Number($('map-photo-overlap').value); + const key=[size,$('map-photo-labels').checked,$('map-photo-push').checked,$('projection-plane').value].join(':'); + if($('map-photos').checked&&mapThumbCase===data&&mapThumbKey===key){ + const rect=projectionCanvas.getBoundingClientRect(); + reanchorThumbnails(projectionHits.map(([x,y])=>[x*rect.width/1200,y*rect.height/600]),rect.width,rect.height); + mapThumbOffsets=new Map(mapThumbLayout.map(b=>[b.id,[b.x-b.anchorX,b.y-b.anchorY]])); + }else{mapThumbOffsets.clear();mapThumbLayout=[];$('map-thumbnail-layer').replaceChildren();} + mapThumbCase=data;mapThumbKey=key; $('map-size-value').value=size+' px';$('map-density-value').value=density+' / MP';$('map-overlap-value').value=overlap+'%'; if(!$('map-photos').checked){$('map-photo-status').textContent='Enable map thumbnails to interpret numeric class IDs as GBIF taxa. Push apart connects displaced images to their fixed prototype points.';return;} if(data.metadata.synthetic){$('map-photo-status').textContent='Synthetic case: no GBIF photo requests.';return;} @@ -135,15 +161,24 @@ async function renderMapThumbnails(){ const remaining=data.names.map((_,i)=>i).sort((a,b)=>(Math.imul(a,2654435761)>>>0)-(Math.imul(b,2654435761)>>>0)); const priority=[selected,...data.neighbours[selected],...remaining].filter(i=>photoNumber(data.names[i])&&!mapThumbFailures.has(data.names[i])); const {boxes,budget}=thumbnailLayout(points,priority,{...config,push:false}); + const ranks=new Map(boxes.map((b,i)=>[b.id,i])); + const retained=new Map(); + for(const box of mapThumbLayout){ + if(ranks.has(box.id)&&box.photoChoice===(photoChoices.get(data.names[box.id])||0))retained.set(box.id,box); + else{box.button.remove();box.tether?.remove();} + } + mapThumbLayout=[...retained.values()].sort((a,b)=>ranks.get(a.id)-ranks.get(b.id)); + let motionFinished=false; let loaded=0,unavailable=0; const status=()=>{$('map-photo-status').textContent=`${loaded} images loaded · ${motionFinished?mapThumbLayout.length:boxes.length} visible slots · budget ${budget} for this viewport. ${config.push?`Push apart: ${motionFinished?'settled; displayed rectangles do not overlap':'spring attraction and soft repulsion; temporary overlap while moving'}. Images move at most ${2*size} px.`:`Maximum pairwise rectangle overlap ${Math.round(config.overlap*100)}%.`} ${unavailable} unavailable. Hover for names and credits; click to inspect. Prototype coordinates and neighbour ranks stay fixed.`;}; if(!boxes.length){status();return;} - try{const health=await fetch('/api/health',{signal});if(!health.ok||!(await health.json()).photo_api)throw Error('Photo server unavailable');} + try{if(boxes.some(b=>!retained.has(b.id))){const health=await fetch('/api/health',{signal});if(!health.ok||!(await health.json()).photo_api)throw Error('Photo server unavailable');}} catch(error){if(!signal.aborted)$('map-photo-status').textContent='Map photos require the local GBIF photo server. Numerical views remain available.';return;} const loadBox=async box=>{ if(signal.aborted||generation!==mapThumbGeneration||data!==caseData)return; const id=data.names[box.id]; + if(retained.has(box.id)){loaded++;const old=retained.get(box.id);old.button.style.zIndex=String(boxes.length-ranks.get(box.id));old.button.style.borderColor=box.id===selected?'#c2344b':data.neighbours[selected].includes(box.id)?'#087e8b':'#7c90a3';return;} try{ let album=photoAlbums.get(id); if(!album){const response=await fetch('/api/gbif/'+id,{signal});album=await response.json();if(!response.ok)throw Error(album.error||'Photo lookup failed');photoAlbums.set(id,album);} @@ -160,9 +195,12 @@ async function renderMapThumbnails(){ const okay=(image.complete&&image.naturalWidth>0)||await loadReferenceImage(image,photo.image_path,signal); if(signal.aborted||generation!==mapThumbGeneration)return; if(okay){ - box.button=button;box.tether=tether;box.readyAt=performance.now()+80; + box.button=button;box.tether=tether;box.photoChoice=photoChoices.get(id)||0;box.readyAt=performance.now()+80; + const offset=config.push?mapThumbOffsets.get(box.id):null; + if(offset){box.x=Math.max(box.width/2,Math.min(config.width-box.width/2,box.anchorX+offset[0]));box.y=Math.max(box.height/2,Math.min(config.height-box.height/2,box.anchorY+offset[1]));box.left=box.x-box.width/2;box.top=box.y-box.height/2;button.style.left=box.x+'px';button.style.top=box.y+'px';} if(tether)$('map-thumbnail-layer').append(tether);$('map-thumbnail-layer').append(button); - mapThumbLayout.push(box);mapThumbLayout.sort((a,b)=>boxes.indexOf(a)-boxes.indexOf(b)); + const bounds=button.getBoundingClientRect();box.width=bounds.width;box.height=bounds.height;box.left=box.x-box.width/2;box.top=box.y-box.height/2; + mapThumbLayout.push(box);mapThumbLayout.sort((a,b)=>ranks.get(a.id)-ranks.get(b.id)); if(config.push)animateThumbnails(config); loaded++;mapThumbImages.delete(photo.image_path);mapThumbImages.set(photo.image_path,image);while(mapThumbImages.size>128)mapThumbImages.delete(mapThumbImages.keys().next().value);} else{button.remove();tether?.remove();mapThumbLayout=mapThumbLayout.filter(b=>b!==box);mapThumbFailures.add(id);unavailable++;} @@ -179,5 +217,6 @@ async function renderMapThumbnails(){ } for(const name of ['map-photos','map-photo-size','map-photo-density','map-photo-overlap','map-photo-labels','map-photo-push'])$(name).oninput=drawProjection; $('map-photo-retry').onclick=()=>{mapThumbFailures.clear();scheduleMapThumbnails();}; +$('map-photo-anchors').oninput=setThumbnailAnchors;setThumbnailAnchors(); new ResizeObserver(scheduleMapThumbnails).observe(projectionCanvas); scheduleMapThumbnails(); From 9cd5b06c20b5f064b8192769dc8324432e715eec Mon Sep 17 00:00:00 2001 From: asgersvenning Date: Thu, 10 Sep 2026 23:59:01 +0200 Subject: [PATCH 12/34] feat: preserve photo proportions and soften thumbnail settling --- dev/prototype_space/README.md | 21 ++++++--- dev/prototype_space/check_browser.mjs | 29 +++++++++++++ dev/prototype_space/report.html | 4 +- dev/prototype_space/thumbnails.js | 62 +++++++++++++++++++++------ 4 files changed, 95 insertions(+), 21 deletions(-) diff --git a/dev/prototype_space/README.md b/dev/prototype_space/README.md index 25469d52..1df572e2 100644 --- a/dev/prototype_space/README.md +++ b/dev/prototype_space/README.md @@ -114,9 +114,17 @@ image size (32–160 CSS pixels), density (5–200 images per megapixel of map v and allowable pairwise rectangle overlap (0–75%). The density gives a maximum budget, capped at 200 images; viewport and overlap culling can produce fewer. The overlap fraction uses the full thumbnail rectangle including its class label -when labels are enabled. Disable **Labels beneath images** for square image-only +when labels are enabled, normalized by the smaller rectangle for unequal sizes. +Disable **Labels beneath images** for square image-only thumbnails with no caption, border or padding; images fill the square by cropping -as needed. Names, IDs and credits remain available on hover or keyboard focus. +as needed. Enable **Original aspect ratio** to preserve image proportions without +cropping; the size slider then sets the longest image edge. This works with or +without labels. Collision forces use the measured rectangular footprint. Cached +image proportions also inform candidate culling; unknown images initially reserve +a conservative square until decoding supplies their dimensions. With pushing +disabled, unknown proportions reserve nonoverlapping squares so a later narrow +image cannot become fully covered under a misleading overlap fraction. Names, IDs and +credits remain available on hover or keyboard focus. Images initially stay centered on actual projected points. Enable **Push thumbnails apart** to admit candidates using the overlap setting, then separate their @@ -128,8 +136,11 @@ and velocities are measured in screen pixels, independent of projection zoom. Images may move up to twice their configured size in screen pixels. **Show thumbnail anchors** toggles the anchor markers and leader lines without reloading images or restarting the simulation. They are hidden by default. -Unresolved collisions are culled in priority order, -so settled rectangles do not overlap. Temporary overlap is visible during +Residual motion eases to rest over the final 24 simulation steps. Unresolved +collisions then fade out over 280 ms before removal, in priority order, so settled +rectangles do not overlap. Pan/zoom or new arrivals cancel a pending fade and +restore its thumbnails; no delayed removal survives that interruption. Reduced +motion skips the fade. Temporary overlap is visible during animation. The simulation cools over about 2.7 seconds after the last arrival, then stops requesting frames; reduced-motion preferences settle immediately. The status reports the remaining visible slots. Increase allowable overlap to try @@ -146,7 +157,7 @@ follow their transformed anchors immediately, retaining their screen-pixel offsets and DOM image nodes. After a short pause, the visible candidate set is updated and collision adjustment resumes; surviving images are neither reloaded nor faded in again. No new images are fetched while dragging continuously. -Changing image size, label mode, push mode, case or projection plane rebuilds the +Changing image size, aspect ratio mode, label mode, push mode, case or projection plane rebuilds the layout because its footprints or coordinate meaning have changed. Cached metadata is shared with the class cards, and changing a class example also refreshes map thumbnails. Decoded image references are bounded to 128 entries; server caching diff --git a/dev/prototype_space/check_browser.mjs b/dev/prototype_space/check_browser.mjs index 9128c7fe..99a28ea5 100644 --- a/dev/prototype_space/check_browser.mjs +++ b/dev/prototype_space/check_browser.mjs @@ -95,6 +95,13 @@ try{ check(thumbnailLayout(fixturePoints,[0,1,2,3],{...config,size:140}).boxes.length===1,'thumbnail size changes culling'); const compact=thumbnailLayout([[32,32],[96,32]],[0,1],{...config,width:128,height:64,labels:false,density:2000}); check(compact.boxes.length===2&&compact.boxes.every(b=>b.width===64&&b.height===64),'image-only culling has no label or padding footprint'); + const wideFootprint=thumbnailFootprint(80,false,2),tallFootprint=thumbnailFootprint(80,true,.5); + check(wideFootprint.width===80&&wideFootprint.height===40&&tallFootprint.width===48&&tallFootprint.height===104,'original aspect footprints include image proportions and optional labels'); + const aspectSlots=thumbnailLayout([[40,20],[40,60]],[0,1],{width:80,height:80,size:80,density:1000,overlap:0,labels:false,aspects:[2,2]}); + check(aspectSlots.boxes.length===2,'aspect-aware culling uses rectangular footprints'); + check(thumbnailLayout([[100,100],[130,100]],[0,1],{width:200,height:200,size:64,density:200,overlap:.75,unknownOverlap:0,labels:false}).boxes.length===1,'unknown aspect ratios reserve nonoverlapping boxes when forces are disabled'); + check(thumbnailLayout([[100,100],[100,100]],[0,1],{width:200,height:200,size:64,density:200,overlap:.5,labels:false,aspects:[4,1]}).boxes.length===1,'overlap limit protects the smaller rectangle from being covered'); + const originalPoints=JSON.stringify(fixturePoints),pushed=thumbnailLayout(fixturePoints,[0,1,2,3],{...config,overlap:.5,push:true}); check(pushed.boxes.length===3&&pushed.boxes.some(b=>b.x!==b.anchorX||b.y!==b.anchorY),'push apart retains and separates overlapping candidates'); const separate=boxes=>boxes.every((b,i)=>boxes.slice(i+1).every(other=>thumbnailSharedArea(b,other)<1e-8)); @@ -140,6 +147,20 @@ try{ await moving;check(mapThumbMotion===null&&separate(mapThumbLayout),'force simulation cools and stops without residual overlaps'); const originalMatchMedia=window.matchMedia;window.matchMedia=()=>({matches:true});await animateThumbnails(motionConfig);window.matchMedia=originalMatchMedia; check(mapThumbMotion===null&&separate(mapThumbLayout),'reduced motion settles without scheduling animation'); + const savedLayout=mapThumbLayout; + const makeCrowded=()=>[0,1].map(i=>{ + const b={id:i,x:32,y:32,anchorX:32,anchorY:32,left:0,top:0,width:64,height:64,vx:0,vy:0}; + b.button=savedLayout[0].button.cloneNode(true);b.tether=thumbnailTether(b,true);document.getElementById('map-thumbnail-layer').append(b.button,b.tether);return b; + }); + mapThumbLayout=makeCrowded();const crowded=mapThumbLayout;const fading=animateThumbnails({width:64,height:64,size:64});mapThumbMotion.step=159; + await new Promise(resolve=>setTimeout(resolve,100)); + check(mapThumbMotion?.phase==='fading'&&crowded.every(b=>b.button.isConnected)&&Number(crowded[1].button.style.opacity)>0&&Number(crowded[1].button.style.opacity)<1,'unresolved collision fades before thumbnail removal'); + await fading;check(mapThumbLayout.length===1&&!crowded[1].button.isConnected,'collision removal completes after fade');crowded[0].button.remove();crowded[0].tether.remove(); + mapThumbLayout=makeCrowded();const interruptedBoxes=mapThumbLayout;const interruptedFade=animateThumbnails({width:64,height:64,size:64});mapThumbMotion.step=159; + await new Promise(resolve=>setTimeout(resolve,90));stopThumbnailMotion();await interruptedFade;await new Promise(resolve=>setTimeout(resolve,300)); + check(interruptedBoxes.every(b=>b.button.isConnected&&b.button.style.opacity===''),'interrupted fade restores thumbnails without delayed removal'); + for(const b of interruptedBoxes){b.button.remove();b.tether.remove();}mapThumbLayout=savedLayout;paintThumbnailMotion(); + const cancelled=animateThumbnails(motionConfig);stopThumbnailMotion();await cancelled; check(mapThumbMotion===null,'force animation cancellation resolves pending work'); document.getElementById('map-photos').checked=false;scheduleMapThumbnails();document.getElementById('map-photos').checked=true;document.getElementById('map-photo-density').value='5';mapThumbImages.clear(); @@ -149,6 +170,14 @@ try{ check(activeImages>1&&activeImages<=4&&mapThumbLayout.length===0&&mapThumbMotion===null,'bounded concurrent image loads do not move unloaded thumbnails'); await delayed;loadReferenceImage=originalLoad; check(maxImages<=4&&mapThumbLayout.length>0&&mapThumbLayout.every(b=>b.button.querySelector('img').naturalWidth>0),'only visible decoded images enter force layout'); + const wideImage='data:image/svg+xml,'+encodeURIComponent(''); + const portraitImage='data:image/svg+xml,'+encodeURIComponent(''); + let albumIndex=0;for(const album of photoAlbums.values()){for(const photo of album.photos)photo.image_path=albumIndex%2?wideImage:portraitImage;albumIndex++;} + document.getElementById('map-photo-aspect').checked=true;document.getElementById('map-photo-aspect').dispatchEvent(new Event('input'));clearTimeout(mapThumbTimer);await renderMapThumbnails(); + check(mapThumbLayout.length>0&&mapThumbLayout.every(b=>{const image=b.button.querySelector('img'),rect=image.getBoundingClientRect();return Math.abs(rect.width/rect.height-image.naturalWidth/image.naturalHeight)<.002&&Math.abs(b.width-rect.width)<.02&&Math.abs(b.height-rect.height)<.02;}),'original aspect option displays uncropped images with matching collision boxes'); + document.getElementById('map-photo-aspect').checked=false;document.getElementById('map-photo-aspect').dispatchEvent(new Event('input'));clearTimeout(mapThumbTimer);await renderMapThumbnails(); + check(mapThumbLayout.every(b=>Math.abs(b.width-b.height)<.01&&getComputedStyle(b.button.querySelector('img')).objectFit==='cover'),'square crop remains selectable'); + const currentBox=mapThumbLayout[0];check(mapThumbnailHit([currentBox.x*1200/projectionCanvas.getBoundingClientRect().width,currentBox.y*600/projectionCanvas.getBoundingClientRect().height])===currentBox.id,'displaced thumbnail hit testing'); diff --git a/dev/prototype_space/report.html b/dev/prototype_space/report.html index fe4c3f7e..d75e4f5f 100644 --- a/dev/prototype_space/report.html +++ b/dev/prototype_space/report.html @@ -17,7 +17,7 @@ @media(max-width:850px){.grid{grid-template-columns:1fr}.stats{grid-template-columns:1fr 1fr}.controls{position:static}main{padding:16px 12px}.panel{padding:17px}header{padding:24px}.controls input{width:160px}} .photo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:14px}.photo-card{border:1px solid var(--line);border-radius:8px;padding:10px;min-width:0}.photo-selected{border:2px solid var(--accent)}.photo-open{width:100%;padding:0;border:0;background:#edf2f5;height:150px;overflow:hidden}.photo-open img{display:block;width:100%;height:100%;object-fit:contain}.photo-title{display:block;margin-top:8px}.photo-credit{font-size:10px;color:var(--muted);overflow-wrap:anywhere;margin:8px 0 0!important}.photo-credit a{color:var(--accent)}.photo-card button:not(.photo-open){font-size:11px;padding:4px 6px} @keyframes thumb-appear{from{opacity:0}to{opacity:1}} -.map-thumb{animation:thumb-appear .18s ease-out;position:absolute;transform:translate(-50%,-50%);padding:2px;border:2px solid #087e8b;border-radius:4px;background:white;pointer-events:none;overflow:hidden;box-shadow:0 1px 4px #0003}.map-thumb img{display:block;width:100%;object-fit:contain;background:#f5f7fa}.map-thumb span{display:block;font-size:10px;height:16px;line-height:16px;white-space:nowrap;overflow:hidden}.map-thumb.image-only{padding:0;border:0;border-radius:0;box-shadow:none}.map-thumb.image-only img{object-fit:cover;background:transparent}.map-thumb.image-only:focus-visible{outline-offset:-3px}.hide-thumb-anchors .map-thumb-tether{display:none}.map-thumb-tether{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;overflow:visible}.map-thumb:focus-visible{outline:3px solid #ffae3d} +.map-thumb{animation:thumb-appear .18s ease-out;position:absolute;transform:translate(-50%,-50%);padding:2px;border:2px solid #087e8b;border-radius:4px;background:white;pointer-events:none;overflow:hidden;box-shadow:0 1px 4px #0003}.map-thumb img{display:block;width:100%;object-fit:contain;background:#f5f7fa}.map-thumb span{display:block;font-size:10px;height:16px;line-height:16px;white-space:nowrap;overflow:hidden}.map-thumb.image-only{padding:0;border:0;border-radius:0;box-shadow:none}.map-thumb.image-only img{object-fit:cover;background:transparent}.map-thumb.original-aspect img{object-fit:contain}.map-thumb.image-only:focus-visible{outline-offset:-3px}.hide-thumb-anchors .map-thumb-tether{display:none}.map-thumb-tether{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;overflow:visible}.map-thumb:focus-visible{outline:3px solid #ffae3d} @media(prefers-reduced-motion:reduce){.map-thumb{animation:none}}
mini_trainer · empirical exploration

Prototype observatory

Move between the global tree, pairwise structure and a class’s local neighbourhood. The existing mini_trainer distance is the reference; the pre-CDF z-score exposes detail that can be hidden by probability rounding.

@@ -35,7 +35,7 @@

Prototype space · spatial projection

Compare linear PCA with angular t-SNE when available. Scroll to zoom, drag to pan, click a point or thumbnail to inspect its class. Each case is fitted separately.

-
+

Hover a point for its class ID.

Red: selected class · teal: its neighbours in the original space · orange rings: nearby in this plane only · grey: other classes. Links follow the original-space ranking. Projected gaps and apparent overlaps can differ from the full embedding geometry.

diff --git a/dev/prototype_space/thumbnails.js b/dev/prototype_space/thumbnails.js index 5bbff0e6..417f0b3f 100644 --- a/dev/prototype_space/thumbnails.js +++ b/dev/prototype_space/thumbnails.js @@ -45,7 +45,9 @@ function relaxThumbnails(boxes,width,height,maxShift){ let mapThumbMotion=null; function stopThumbnailMotion(){ if(!mapThumbMotion)return; - cancelAnimationFrame(mapThumbMotion.frame);mapThumbMotion.resolve();mapThumbMotion=null; + cancelAnimationFrame(mapThumbMotion.frame); + for(const box of mapThumbMotion.fading||[]){box.button.style.removeProperty('opacity');box.tether?.style.removeProperty('opacity');} + mapThumbMotion.resolve();mapThumbMotion=null; } function paintThumbnailMotion(){ for(const box of mapThumbLayout){ @@ -62,14 +64,36 @@ function paintThumbnailMotion(){ function animateThumbnails(config){ stopThumbnailMotion(); return new Promise(resolve=>{ - const motion={frame:0,step:0,resolve,last:0};mapThumbMotion=motion; + const motion={frame:0,step:0,resolve,last:0,phase:'moving',fading:[]};mapThumbMotion=motion; + const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches; const finish=()=>{ const kept=cullThumbnailCollisions(mapThumbLayout),keep=new Set(kept); - for(const box of mapThumbLayout)if(!keep.has(box)){box.button.remove();box.tether.remove();} - mapThumbLayout=kept;paintThumbnailMotion();mapThumbMotion=null;resolve(); + motion.fading=mapThumbLayout.filter(box=>!keep.has(box)); + const remove=()=>{ + for(const box of motion.fading){box.button.remove();box.tether?.remove();} + mapThumbLayout=kept;paintThumbnailMotion();mapThumbMotion=null;resolve(); + }; + if(reduced||!motion.fading.length){remove();return;} + motion.phase='fading';let started=null; + const fade=time=>{ + if(mapThumbMotion!==motion)return; + started??=time;const t=Math.min(1,(time-started)/280),opacity=1-t*t*(3-2*t); + for(const box of motion.fading){box.button.style.opacity=opacity; if(box.tether)box.tether.style.opacity=opacity;} + if(t===1){remove();return;} + motion.frame=requestAnimationFrame(fade); + }; + motion.frame=requestAnimationFrame(fade); + }; + const advance=()=>{ + const active=mapThumbLayout.filter(b=>reduced||!b.readyAt||performance.now()>=b.readyAt); + const previous=active.map(b=>[b.x,b.y]); + stepThumbnailForce(active,config.width,config.height,2*config.size,Math.max(0,1-motion.step/120)); + // Ease residual movement to rest instead of freezing nonzero velocity on + // the final frame. Interpolating within each step retains viewport bounds. + const ease=reduced?1:Math.min(1,((159-motion.step)/24)**2); + active.forEach((b,i)=>{b.x=previous[i][0]+(b.x-previous[i][0])*ease;b.y=previous[i][1]+(b.y-previous[i][1])*ease;b.vx*=ease;b.vy*=ease;b.left=b.x-b.width/2;b.top=b.y-b.height/2;}); + motion.step++; }; - const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches; - const advance=()=>{stepThumbnailForce(mapThumbLayout.filter(b=>reduced||!b.readyAt||performance.now()>=b.readyAt),config.width,config.height,2*config.size,Math.max(0,1-motion.step/120));motion.step++;}; if(reduced){while(motion.step<160)advance();finish();return;} const tick=time=>{ if(mapThumbMotion!==motion)return; @@ -82,15 +106,21 @@ function animateThumbnails(config){ motion.frame=requestAnimationFrame(tick); }); } -function thumbnailLayout(points, priority, {width,height,size,density,overlap,labels=true,push=false}) { +function thumbnailFootprint(size,labels,aspect=1){ + const ratio=Number.isFinite(aspect)&&aspect>0?aspect:1; + const imageWidth=size*Math.min(1,ratio),imageHeight=size*Math.min(1,1/ratio); + return {imageWidth,imageHeight,width:imageWidth+(labels?8:0),height:imageHeight+(labels?24:0)}; +} +function thumbnailLayout(points, priority, {width,height,size,density,overlap,labels=true,push=false,aspects=[],unknownOverlap=overlap}) { const budget=Math.min(200,Math.max(1,Math.floor(width*height*density/1e6))); - const chosen=[],used=new Set();const boxWidth=labels?size+8:size,boxHeight=labels?size+24:size; + const chosen=[],used=new Set(); for(const id of priority){ if(used.has(id))continue;used.add(id);const [x,y]=points[id]; + const {width:boxWidth,height:boxHeight}=thumbnailFootprint(size,labels,aspects[id]); const box={id,x,y,anchorX:x,anchorY:y,left:x-boxWidth/2,top:y-boxHeight/2,width:boxWidth,height:boxHeight}; if(box.left<0||box.top<0||box.left+boxWidth>width||box.top+boxHeight>height)continue; const area=boxWidth*boxHeight; - const blocked=chosen.some(other=>thumbnailSharedArea(box,other)/area>overlap+1e-12); + const blocked=chosen.some(other=>{const limit=aspects[id]>0&&aspects[other.id]>0?overlap:unknownOverlap;return thumbnailSharedArea(box,other)/Math.min(area,other.width*other.height)>limit+1e-12;}); if(!blocked)chosen.push(box);if(chosen.length>=budget)break; } const boxes=push?relaxThumbnails(chosen,width,height,2*size):chosen; @@ -137,7 +167,7 @@ function setThumbnailAnchors(){ function scheduleMapThumbnails(){ clearTimeout(mapThumbTimer);stopThumbnailMotion();mapThumbController?.abort();++mapThumbGeneration; const size=Number($('map-photo-size').value),density=Number($('map-photo-density').value),overlap=Number($('map-photo-overlap').value); - const key=[size,$('map-photo-labels').checked,$('map-photo-push').checked,$('projection-plane').value].join(':'); + const key=[size,$('map-photo-labels').checked,$('map-photo-push').checked,$('map-photo-aspect').checked,$('projection-plane').value].join(':'); if($('map-photos').checked&&mapThumbCase===data&&mapThumbKey===key){ const rect=projectionCanvas.getBoundingClientRect(); reanchorThumbnails(projectionHits.map(([x,y])=>[x*rect.width/1200,y*rect.height/600]),rect.width,rect.height); @@ -154,13 +184,14 @@ async function renderMapThumbnails(){ const generation=mapThumbGeneration,caseData=data; mapThumbController=new AbortController();const signal=mapThumbController.signal; const rect=projectionCanvas.getBoundingClientRect(),size=Number($('map-photo-size').value); - const config={width:rect.width,height:rect.height,size,density:Number($('map-photo-density').value),overlap:Number($('map-photo-overlap').value)/100,labels:$('map-photo-labels').checked,push:$('map-photo-push').checked}; + const config={width:rect.width,height:rect.height,size,density:Number($('map-photo-density').value),overlap:Number($('map-photo-overlap').value)/100,labels:$('map-photo-labels').checked,push:$('map-photo-push').checked,aspect:$('map-photo-aspect').checked}; + if(config.aspect)config.aspects=data.names.map(id=>{const album=photoAlbums.get(id),photo=album?.photos[(photoChoices.get(id)||0)%(album?.photos.length||1)],image=photo&&mapThumbImages.get(photo.image_path);return image?.naturalWidth/image?.naturalHeight;}); const points=projectionHits.map(([x,y])=>[x*rect.width/1200,y*rect.height/600]); // Selected class first, then its true neighbours; other classes have stable // mixed row order so dense regions do not win simply by checkpoint ordering. const remaining=data.names.map((_,i)=>i).sort((a,b)=>(Math.imul(a,2654435761)>>>0)-(Math.imul(b,2654435761)>>>0)); const priority=[selected,...data.neighbours[selected],...remaining].filter(i=>photoNumber(data.names[i])&&!mapThumbFailures.has(data.names[i])); - const {boxes,budget}=thumbnailLayout(points,priority,{...config,push:false}); + const {boxes,budget}=thumbnailLayout(points,priority,{...config,push:false,unknownOverlap:config.aspect&&!config.push?0:config.overlap}); const ranks=new Map(boxes.map((b,i)=>[b.id,i])); const retained=new Map(); for(const box of mapThumbLayout){ @@ -187,7 +218,7 @@ async function renderMapThumbnails(){ const choice=(photoChoices.get(id)||0)%album.photos.length,photo=album.photos[choice]; let image=mapThumbImages.get(photo.image_path)?.cloneNode(); const button=photoText('button','','map-thumb');button.type='button';button.style.left=box.x+'px';button.style.top=box.y+'px';button.style.width=box.width+'px';button.style.height=box.height+'px';button.style.zIndex=String(boxes.length-boxes.indexOf(box));button.style.borderColor=box.id===selected?'#c2344b':data.neighbours[selected].includes(box.id)?'#087e8b':'#7c90a3';button.setAttribute('aria-label',`Inspect ${album.display_name}, class ${id}`);button.onclick=()=>selectClass(box.id);button.onfocus=()=>showMapPhotoCredit(box.id); - button.classList.toggle('image-only',!config.labels);button.title=`${album.display_name} · class ${id}`; + button.classList.toggle('image-only',!config.labels);button.classList.toggle('original-aspect',config.aspect);button.title=`${album.display_name} · class ${id}`; if(!image){image=new Image();image.decoding='async';} image.draggable=false;image.alt=album.display_name;image.style.height=size+'px'; button.append(image);if(config.labels)button.append(photoText('span',id)); @@ -195,6 +226,9 @@ async function renderMapThumbnails(){ const okay=(image.complete&&image.naturalWidth>0)||await loadReferenceImage(image,photo.image_path,signal); if(signal.aborted||generation!==mapThumbGeneration)return; if(okay){ + const footprint=thumbnailFootprint(size,config.labels,config.aspect?image.naturalWidth/image.naturalHeight:1); + box.width=footprint.width;box.height=footprint.height;box.left=box.x-box.width/2;box.top=box.y-box.height/2; + button.style.width=box.width+'px';button.style.height=box.height+'px';image.style.height=footprint.imageHeight+'px'; box.button=button;box.tether=tether;box.photoChoice=photoChoices.get(id)||0;box.readyAt=performance.now()+80; const offset=config.push?mapThumbOffsets.get(box.id):null; if(offset){box.x=Math.max(box.width/2,Math.min(config.width-box.width/2,box.anchorX+offset[0]));box.y=Math.max(box.height/2,Math.min(config.height-box.height/2,box.anchorY+offset[1]));box.left=box.x-box.width/2;box.top=box.y-box.height/2;button.style.left=box.x+'px';button.style.top=box.y+'px';} @@ -215,7 +249,7 @@ async function renderMapThumbnails(){ if(config.push&&!signal.aborted&&generation===mapThumbGeneration)await animateThumbnails(config); if(generation===mapThumbGeneration){motionFinished=true;status();showMapPhotoCredit(selected);} } -for(const name of ['map-photos','map-photo-size','map-photo-density','map-photo-overlap','map-photo-labels','map-photo-push'])$(name).oninput=drawProjection; +for(const name of ['map-photos','map-photo-size','map-photo-density','map-photo-overlap','map-photo-labels','map-photo-push','map-photo-aspect'])$(name).oninput=drawProjection; $('map-photo-retry').onclick=()=>{mapThumbFailures.clear();scheduleMapThumbnails();}; $('map-photo-anchors').oninput=setThumbnailAnchors;setThumbnailAnchors(); new ResizeObserver(scheduleMapThumbnails).observe(projectionCanvas); From 997632e86d2664c108ee09796b65979068098209 Mon Sep 17 00:00:00 2001 From: asgersvenning Date: Fri, 11 Sep 2026 00:07:07 +0200 Subject: [PATCH 13/34] fix: retain thumbnails and settle by measured convergence --- dev/prototype_space/README.md | 27 ++++-- dev/prototype_space/check_browser.mjs | 23 +++-- dev/prototype_space/check_layout.mjs | 37 ++++++++ dev/prototype_space/thumbnails.js | 117 ++++++++++++++------------ 4 files changed, 127 insertions(+), 77 deletions(-) create mode 100644 dev/prototype_space/check_layout.mjs diff --git a/dev/prototype_space/README.md b/dev/prototype_space/README.md index 1df572e2..74d64ff9 100644 --- a/dev/prototype_space/README.md +++ b/dev/prototype_space/README.md @@ -136,16 +136,26 @@ and velocities are measured in screen pixels, independent of projection zoom. Images may move up to twice their configured size in screen pixels. **Show thumbnail anchors** toggles the anchor markers and leader lines without reloading images or restarting the simulation. They are hidden by default. -Residual motion eases to rest over the final 24 simulation steps. Unresolved -collisions then fade out over 280 ms before removal, in priority order, so settled -rectangles do not overlap. Pan/zoom or new arrivals cancel a pending fade and -restore its thumbnails; no delayed removal survives that interruption. Reduced -motion skips the fade. Temporary overlap is visible during -animation. The simulation cools over about 2.7 seconds after the last arrival, -then stops requesting frames; reduced-motion preferences settle immediately. +Settling never removes or fades an admitted thumbnail. The solver minimizes a +fixed sum of anchor displacement energy and squared, smoothed rectangle-contact +penetration. Contact curvature scales each coordinate's step; damped momentum +and backtracking keep accepted steps from increasing that energy. Anchor +attraction does not decay on a timer. + +Rest requires eight consecutive steps with movement below 0.04 CSS pixels, +energy change below 0.002 per thumbnail, and maximum overlap-fraction change +below 0.0001. At most two solver steps run per animation frame. A 480-step work +limit bounds expensive cases; the status distinguishes **resting** from **work +limit** and reports overlapping pairs that remain visible. Neither resting nor +the work limit guarantees zero overlap or a global energy minimum. Viewport and +movement bounds can make overlap unavoidable. Reduced-motion preferences run +the same bounded solver without animation. + The status reports the remaining visible slots. Increase allowable overlap to try a denser candidate set; -this does not guarantee that every candidate will fit after separation. +this does not guarantee that every admitted image can be separated within the +movement bounds. Initial viewport/density/overlap admission remains separate +from settling; an admitted image is never removed merely because motion stops. Culling prioritizes the selected class and its original-space neighbours, then uses a stable mixed class order. @@ -230,6 +240,7 @@ view payload is in `report-data.json`. Generated data and weights stay out of Gi ```bash bash dev/check.sh static +node dev/prototype_space/check_layout.mjs bash dev/check.sh test tests/utils/test_prototype_exploration.py \ tests/utils/test_dendrogram.py tests/utils/test_plot.py ``` diff --git a/dev/prototype_space/check_browser.mjs b/dev/prototype_space/check_browser.mjs index 99a28ea5..2e250720 100644 --- a/dev/prototype_space/check_browser.mjs +++ b/dev/prototype_space/check_browser.mjs @@ -110,7 +110,7 @@ try{ check(JSON.stringify(fixturePoints)===originalPoints&&JSON.stringify(thumbnailLayout(fixturePoints,[0,1,2,3],{...config,overlap:.5,push:true}))===JSON.stringify(pushed),'push apart is deterministic and preserves source points'); const dense=Array.from({length:60},(_,i)=>[40+(i%10)*18,40+Math.floor(i/10)*18]); const tight=thumbnailLayout(dense,dense.map((_,i)=>i),{...config,width:260,height:180,labels:false,overlap:.75,push:true,density:2000}); - check(tight.boxes.length>0&&separate(tight.boxes)&&tight.boxes.every(b=>b.left>=0&&b.top>=0&&b.left+b.width<=260&&b.top+b.height<=180),'crowded boundary fixture resolves or culls collisions'); + check(tight.boxes.length>0&&tight.culled===0&&tight.boxes.every(b=>b.left>=0&&b.top>=0&&b.left+b.width<=260&&b.top+b.height<=180),'crowded boundary fixture retains all admitted boxes within bounds'); document.getElementById('map-photos').checked=true;scheduleMapThumbnails();clearTimeout(mapThumbTimer);await renderMapThumbnails(); check(document.querySelectorAll('.map-thumb').length>0,'map thumbnails load'); check([...document.querySelectorAll('.map-thumb img')].every(img=>img.naturalWidth>0),'map thumbnails decode'); @@ -121,7 +121,7 @@ try{ document.getElementById('map-photo-labels').checked=false;document.getElementById('map-photo-push').checked=true;document.getElementById('map-photo-overlap').value='75';document.getElementById('map-photo-density').value='200'; scheduleMapThumbnails();clearTimeout(mapThumbTimer);await renderMapThumbnails(); check(document.querySelectorAll('.map-thumb').length>0&&[...document.querySelectorAll('.map-thumb')].every(b=>!b.querySelector('span')&&getComputedStyle(b).padding==='0px'&&getComputedStyle(b).borderWidth==='0px'),'image-only toggle removes labels and whitespace'); - check(separate(mapThumbLayout)&&document.getElementById('map-photo-status').textContent.includes('displayed rectangles do not overlap'),'rendered push mode has no overlaps'); + check(mapThumbRest&&mapThumbRest.pairs===thumbnailEnergy(mapThumbLayout).pairs&&document.getElementById('map-photo-status').textContent.includes('overlapping pairs retained'),'settled status reports residual overlap without hiding it'); check(JSON.stringify(projectionHits)===coordinatesBefore,'thumbnail relaxation preserves projected coordinates'); const displaced=mapThumbLayout.find(b=>Math.hypot(b.x-b.anchorX,b.y-b.anchorY)>=.5); check(displaced&&document.querySelectorAll('.map-thumb-tether').length>0,'displaced photos retain visible anchor markers'); @@ -144,23 +144,20 @@ try{ await new Promise(resolve=>setTimeout(resolve,120)); check(mapThumbMotion!==null&&mapThumbLayout.some((b,i)=>Math.hypot(b.x-startPositions[i][0],b.y-startPositions[i][1])>.1),'force layout advances visibly over animation frames'); check(mapThumbLayout.every(b=>Math.abs(parseFloat(b.button.style.left)-b.x)<.01&&Math.abs(parseFloat(b.button.style.top)-b.y)<.01),'animated images and hit rectangles move together'); - await moving;check(mapThumbMotion===null&&separate(mapThumbLayout),'force simulation cools and stops without residual overlaps'); + await moving;check(mapThumbMotion===null&&mapThumbLayout.length===startPositions.length,'solver stops without removing thumbnails'); const originalMatchMedia=window.matchMedia;window.matchMedia=()=>({matches:true});await animateThumbnails(motionConfig);window.matchMedia=originalMatchMedia; - check(mapThumbMotion===null&&separate(mapThumbLayout),'reduced motion settles without scheduling animation'); + check(mapThumbMotion===null&&mapThumbLayout.length===startPositions.length,'reduced motion preserves the same thumbnail population'); const savedLayout=mapThumbLayout; const makeCrowded=()=>[0,1].map(i=>{ const b={id:i,x:32,y:32,anchorX:32,anchorY:32,left:0,top:0,width:64,height:64,vx:0,vy:0}; b.button=savedLayout[0].button.cloneNode(true);b.tether=thumbnailTether(b,true);document.getElementById('map-thumbnail-layer').append(b.button,b.tether);return b; }); - mapThumbLayout=makeCrowded();const crowded=mapThumbLayout;const fading=animateThumbnails({width:64,height:64,size:64});mapThumbMotion.step=159; - await new Promise(resolve=>setTimeout(resolve,100)); - check(mapThumbMotion?.phase==='fading'&&crowded.every(b=>b.button.isConnected)&&Number(crowded[1].button.style.opacity)>0&&Number(crowded[1].button.style.opacity)<1,'unresolved collision fades before thumbnail removal'); - await fading;check(mapThumbLayout.length===1&&!crowded[1].button.isConnected,'collision removal completes after fade');crowded[0].button.remove();crowded[0].tether.remove(); - mapThumbLayout=makeCrowded();const interruptedBoxes=mapThumbLayout;const interruptedFade=animateThumbnails({width:64,height:64,size:64});mapThumbMotion.step=159; - await new Promise(resolve=>setTimeout(resolve,90));stopThumbnailMotion();await interruptedFade;await new Promise(resolve=>setTimeout(resolve,300)); - check(interruptedBoxes.every(b=>b.button.isConnected&&b.button.style.opacity===''),'interrupted fade restores thumbnails without delayed removal'); - for(const b of interruptedBoxes){b.button.remove();b.tether.remove();}mapThumbLayout=savedLayout;paintThumbnailMotion(); - + mapThumbLayout=makeCrowded();const crowded=mapThumbLayout;await animateThumbnails({width:64,height:64,size:64}); + check(mapThumbLayout===crowded&&crowded.every(b=>b.button.isConnected&&b.button.style.opacity===''),'impossible packing keeps both thumbnails visible at rest'); + check(mapThumbRest.reason==='resting'&&mapThumbRest.pairs===1&&mapThumbRest.steps<480,'constrained equilibrium stops on measured stability and reports overlap'); + await new Promise(resolve=>setTimeout(resolve,300)); + check(crowded.every(b=>b.button.isConnected),'no deferred settling-time removal'); + for(const b of crowded){b.button.remove();b.tether.remove();}mapThumbLayout=savedLayout;paintThumbnailMotion(); const cancelled=animateThumbnails(motionConfig);stopThumbnailMotion();await cancelled; check(mapThumbMotion===null,'force animation cancellation resolves pending work'); document.getElementById('map-photos').checked=false;scheduleMapThumbnails();document.getElementById('map-photos').checked=true;document.getElementById('map-photo-density').value='5';mapThumbImages.clear(); diff --git a/dev/prototype_space/check_layout.mjs b/dev/prototype_space/check_layout.mjs new file mode 100644 index 00000000..f000b7e2 --- /dev/null +++ b/dev/prototype_space/check_layout.mjs @@ -0,0 +1,37 @@ +// Dependency-free numerical checks for the browser's pure layout functions. +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +const source=readFileSync(new URL('./thumbnails.js',import.meta.url),'utf8'); +const {thumbnailEnergy,stepThumbnailForce}=Function(source.slice(source.indexOf('function thumbnailSharedArea'),source.indexOf('let mapThumbMotion'))+'return {thumbnailEnergy,stepThumbnailForce};')(); +const box=(id,x,y,width=64,height=64)=>({id,x,y,anchorX:x,anchorY:y,width,height}); +const gradientCase=[box(0,100,100,72,60),box(1,149,127,64,80),box(2,210,150,50,64)]; +gradientCase[0].anchorX-=12; +const gradient=thumbnailEnergy(gradientCase,true).forces; +for(let i=0;ibox(i,200+i%6*50,180+Math.floor(i/6)*50)),width:900,height:700}, + {name:'impossible two-box viewport',boxes:[box(0,32,32),box(1,32,32)],width:64,height:64} +]){ + const state={},ids=fixture.boxes.map(b=>b.id),anchors=fixture.boxes.map(b=>[b.anchorX,b.anchorY]); + let previous=thumbnailEnergy(fixture.boxes).energy; + while(!state.done){ + stepThumbnailForce(fixture.boxes,fixture.width,fixture.height,128,state); + assert.ok(state.metrics.energy<=previous+1e-8,'accepted steps must not increase energy');previous=state.metrics.energy; + assert.deepEqual(fixture.boxes.map(b=>b.id),ids,'solver must preserve every admitted thumbnail'); + for(const b of fixture.boxes){assert.ok(b.left>=-1e-8&&b.top>=-1e-8&&b.left+b.width<=fixture.width+1e-8&&b.top+b.height<=fixture.height+1e-8);assert.ok(Math.hypot(b.x-b.anchorX,b.y-b.anchorY)<=128+1e-8);} + } + assert.deepEqual(fixture.boxes.map(b=>[b.anchorX,b.anchorY]),anchors); + assert.equal(state.reason,'resting');assert.ok(state.quiet>=8); + if(fixture.name.startsWith('feasible'))assert.equal(state.metrics.pairs,0); + if(fixture.name.startsWith('impossible'))assert.equal(state.metrics.pairs,1); + results.push({fixture:fixture.name,steps:state.steps,overlappingPairs:state.metrics.pairs}); +} +console.log(JSON.stringify({gradientChecks:6,fixtures:results})); diff --git a/dev/prototype_space/thumbnails.js b/dev/prototype_space/thumbnails.js index 417f0b3f..3a282db9 100644 --- a/dev/prototype_space/thumbnails.js +++ b/dev/prototype_space/thumbnails.js @@ -6,47 +6,69 @@ const mapThumbFailures = new Set(); function thumbnailSharedArea(a,b){ return Math.max(0,Math.min(a.left+a.width,b.left+b.width)-Math.max(a.left,b.left))*Math.max(0,Math.min(a.top+a.height,b.top+b.height)-Math.max(a.top,b.top)); } -function stepThumbnailForce(boxes,width,height,maxShift,heat){ - const forces=boxes.map(b=>[(b.anchorX-b.x)*.014*heat,(b.anchorY-b.y)*.014*heat]); +function thumbnailEnergy(boxes,gradient=false){ + let energy=0,overlap=0,pairs=0; + // Diagonal contact-curvature estimate preconditions crowded and free axes + // differently; a floor bounds steps along weakly constrained directions. + const curvature=gradient?boxes.map(()=>[.05,.05]):null; + const forces=gradient?boxes.map(b=>[(b.anchorX-b.x)*.003,(b.anchorY-b.y)*.003]):null; + for(const b of boxes)energy+=.0015*((b.x-b.anchorX)**2+(b.y-b.anchorY)**2); for(let i=0;i2&&py>2){pairs++;overlap=Math.max(overlap,(px-2)*(py-2)/Math.min(a.width*a.height,b.width*b.height));} + // Smooth minimum penetration of the actual border boxes. The forces below + // are the negative gradient of this same, time-independent contact energy. + const depth=Math.min(px,py),wx=Math.exp(-(px-depth)/2),wy=Math.exp(-(py-depth)/2); + const contact=Math.max(0,depth-2*Math.log(wx+wy));energy+=.5*contact*contact; + if(!gradient||contact===0)continue; const angle=((a.id*137.508+b.id*71.3)%360)*Math.PI/180; const sx=dx===0?(Math.cos(angle)<0?-1:1):Math.sign(dx),sy=dy===0?(Math.sin(angle)<0?-1:1):Math.sign(dy); - const fx=sx*.24*depth*wx/(wx+wy),fy=sy*.24*depth*wy/(wx+wy); + const fx=sx*contact*wx/(wx+wy),fy=sy*contact*wy/(wx+wy); + const hx=(wx/(wx+wy))**2,hy=(wy/(wx+wy))**2;curvature[i][0]+=hx;curvature[j][0]+=hx;curvature[i][1]+=hy;curvature[j][1]+=hy; forces[i][0]-=fx;forces[i][1]-=fy;forces[j][0]+=fx;forces[j][1]+=fy; } - let speed=0; - boxes.forEach((b,i)=>{ - b.vx=((b.vx||0)+forces[i][0])*.72;b.vy=((b.vy||0)+forces[i][1])*.72; - const velocity=Math.hypot(b.vx,b.vy);if(velocity>8){b.vx*=8/velocity;b.vy*=8/velocity;} - let x=Math.max(b.width/2,Math.min(width-b.width/2,b.x+b.vx)),y=Math.max(b.height/2,Math.min(height-b.height/2,b.y+b.vy)); - const distance=Math.hypot(x-b.anchorX,y-b.anchorY); - if(distance>maxShift){x=b.anchorX+(x-b.anchorX)*maxShift/distance;y=b.anchorY+(y-b.anchorY)*maxShift/distance;} - speed=Math.max(speed,Math.hypot(x-b.x,y-b.y)); - b.vx=x-b.x;b.vy=y-b.y;b.x=x;b.y=y;b.left=x-b.width/2;b.top=y-b.height/2; - }); - return speed; + return {energy,overlap,pairs,forces,curvature}; } -function cullThumbnailCollisions(boxes){ - const kept=[]; - for(const box of boxes)if(!kept.some(other=>thumbnailSharedArea(box,other)>1e-8))kept.push(box); - return kept; +function stepThumbnailForce(boxes,width,height,maxShift,state={}){ + if(typeof state!=='object')state={}; + const before=thumbnailEnergy(boxes,true),positions=boxes.map(b=>[b.x,b.y]); + let rate=state.rate||1,after=before,speed=0,accepted=false; + for(let trial=0;trial<14;trial++){ + speed=0; + boxes.forEach((b,i)=>{ + const momentum=trial===0?.55:0; + let dx=before.forces[i][0]*rate/before.curvature[i][0]+(b.vx||0)*momentum,dy=before.forces[i][1]*rate/before.curvature[i][1]+(b.vy||0)*momentum; + const distance=Math.hypot(dx,dy);if(distance>8){dx*=8/distance;dy*=8/distance;} + let x=Math.max(b.width/2,Math.min(width-b.width/2,positions[i][0]+dx)),y=Math.max(b.height/2,Math.min(height-b.height/2,positions[i][1]+dy)); + const offset=Math.hypot(x-b.anchorX,y-b.anchorY); + if(offset>maxShift){x=b.anchorX+(x-b.anchorX)*maxShift/offset;y=b.anchorY+(y-b.anchorY)*maxShift/offset;} + b.x=x;b.y=y;speed=Math.max(speed,Math.hypot(x-positions[i][0],y-positions[i][1])); + }); + after=thumbnailEnergy(boxes); + if(after.energy<=before.energy+1e-9){accepted=true;break;} + // Retry without inertia before shortening the gradient step. + if(trial>0)rate*=.5; + } + if(!accepted){boxes.forEach((b,i)=>{b.x=positions[i][0];b.y=positions[i][1];});after=before;speed=0;} + boxes.forEach((b,i)=>{b.vx=b.x-positions[i][0];b.vy=b.y-positions[i][1];b.left=b.x-b.width/2;b.top=b.y-b.height/2;}); + state.rate=Math.min(4,rate*1.1);state.steps=(state.steps||0)+1; + const small=speed<.04&&Math.abs(after.energy-before.energy)/Math.max(1,boxes.length)<.002&&Math.abs(after.overlap-before.overlap)<1e-4; + state.quiet=small?(state.quiet||0)+1:0; + state.metrics={energy:after.energy,overlap:after.overlap,pairs:after.pairs,speed}; + state.done=state.quiet>=8||state.steps>=480; + state.reason=state.quiet>=8?'resting':'work limit'; + return speed; } function relaxThumbnails(boxes,width,height,maxShift){ - for(let step=0;step<160;step++)stepThumbnailForce(boxes,width,height,maxShift,Math.max(0,1-step/120)); - return cullThumbnailCollisions(boxes); + const state={};while(!state.done)stepThumbnailForce(boxes,width,height,maxShift,state); + return boxes; } let mapThumbMotion=null; function stopThumbnailMotion(){ if(!mapThumbMotion)return; cancelAnimationFrame(mapThumbMotion.frame); - for(const box of mapThumbMotion.fading||[]){box.button.style.removeProperty('opacity');box.tether?.style.removeProperty('opacity');} mapThumbMotion.resolve();mapThumbMotion=null; } function paintThumbnailMotion(){ @@ -61,46 +83,29 @@ function paintThumbnailMotion(){ box.tether.style.visibility=Math.hypot(box.x-box.anchorX,box.y-box.anchorY)<.5?'hidden':'visible'; } } +let mapThumbRest=null; function animateThumbnails(config){ - stopThumbnailMotion(); + stopThumbnailMotion();mapThumbRest=null; return new Promise(resolve=>{ - const motion={frame:0,step:0,resolve,last:0,phase:'moving',fading:[]};mapThumbMotion=motion; + const motion={frame:0,resolve,state:{},last:0};mapThumbMotion=motion; const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches; const finish=()=>{ - const kept=cullThumbnailCollisions(mapThumbLayout),keep=new Set(kept); - motion.fading=mapThumbLayout.filter(box=>!keep.has(box)); - const remove=()=>{ - for(const box of motion.fading){box.button.remove();box.tether?.remove();} - mapThumbLayout=kept;paintThumbnailMotion();mapThumbMotion=null;resolve(); - }; - if(reduced||!motion.fading.length){remove();return;} - motion.phase='fading';let started=null; - const fade=time=>{ - if(mapThumbMotion!==motion)return; - started??=time;const t=Math.min(1,(time-started)/280),opacity=1-t*t*(3-2*t); - for(const box of motion.fading){box.button.style.opacity=opacity; if(box.tether)box.tether.style.opacity=opacity;} - if(t===1){remove();return;} - motion.frame=requestAnimationFrame(fade); - }; - motion.frame=requestAnimationFrame(fade); + for(const box of mapThumbLayout){box.vx=0;box.vy=0;} + mapThumbRest={...motion.state.metrics,reason:motion.state.reason,steps:motion.state.steps}; + paintThumbnailMotion();mapThumbMotion=null;resolve(); }; const advance=()=>{ const active=mapThumbLayout.filter(b=>reduced||!b.readyAt||performance.now()>=b.readyAt); - const previous=active.map(b=>[b.x,b.y]); - stepThumbnailForce(active,config.width,config.height,2*config.size,Math.max(0,1-motion.step/120)); - // Ease residual movement to rest instead of freezing nonzero velocity on - // the final frame. Interpolating within each step retains viewport bounds. - const ease=reduced?1:Math.min(1,((159-motion.step)/24)**2); - active.forEach((b,i)=>{b.x=previous[i][0]+(b.x-previous[i][0])*ease;b.y=previous[i][1]+(b.y-previous[i][1])*ease;b.vx*=ease;b.vy*=ease;b.left=b.x-b.width/2;b.top=b.y-b.height/2;}); - motion.step++; + if(active.length!==mapThumbLayout.length)return; + stepThumbnailForce(active,config.width,config.height,2*config.size,motion.state); }; - if(reduced){while(motion.step<160)advance();finish();return;} + if(reduced){while(!motion.state.done)advance();finish();return;} const tick=time=>{ if(mapThumbMotion!==motion)return; - // Fixed 60 Hz simulation, at most two steps per frame; no catch-up burst - // when a background tab resumes. Work stops entirely once the system cools. - if(!motion.last||time-motion.last>=15){advance();if(motion.last&&time-motion.last>=32&&motion.step<160)advance();motion.last=time;paintThumbnailMotion();} - if(motion.step>=160){finish();return;} + // Fixed 60 Hz presentation; at most two solver steps per frame and no + // catch-up burst after a background tab resumes. + if(!motion.last||time-motion.last>=15){advance();if(!motion.state.done)advance();motion.last=time;paintThumbnailMotion();} + if(motion.state.done){finish();return;} motion.frame=requestAnimationFrame(tick); }; motion.frame=requestAnimationFrame(tick); @@ -202,7 +207,7 @@ async function renderMapThumbnails(){ let motionFinished=false; let loaded=0,unavailable=0; - const status=()=>{$('map-photo-status').textContent=`${loaded} images loaded · ${motionFinished?mapThumbLayout.length:boxes.length} visible slots · budget ${budget} for this viewport. ${config.push?`Push apart: ${motionFinished?'settled; displayed rectangles do not overlap':'spring attraction and soft repulsion; temporary overlap while moving'}. Images move at most ${2*size} px.`:`Maximum pairwise rectangle overlap ${Math.round(config.overlap*100)}%.`} ${unavailable} unavailable. Hover for names and credits; click to inspect. Prototype coordinates and neighbour ranks stay fixed.`;}; + const status=()=>{$('map-photo-status').textContent=`${loaded} images loaded · ${motionFinished?mapThumbLayout.length:boxes.length} visible slots · budget ${budget} for this viewport. ${config.push?`Push apart: ${motionFinished?`${mapThumbRest?.reason||'resting'} · ${mapThumbRest?.pairs||0} overlapping pairs retained`:'adjusting rectangle contacts'}. Images move at most ${2*size} px.`:`Maximum pairwise rectangle overlap ${Math.round(config.overlap*100)}%.`} ${unavailable} unavailable. Hover for names and credits; click to inspect. Prototype coordinates and neighbour ranks stay fixed.`;}; if(!boxes.length){status();return;} try{if(boxes.some(b=>!retained.has(b.id))){const health=await fetch('/api/health',{signal});if(!health.ok||!(await health.json()).photo_api)throw Error('Photo server unavailable');}} catch(error){if(!signal.aborted)$('map-photo-status').textContent='Map photos require the local GBIF photo server. Numerical views remain available.';return;} From f4ed9841c2ead7b520a9297fdca9f3ad11b3e714 Mon Sep 17 00:00:00 2001 From: asgersvenning Date: Fri, 11 Sep 2026 00:13:38 +0200 Subject: [PATCH 14/34] fix: ease thumbnail motion independently of solver steps --- dev/prototype_space/README.md | 14 +++++++ dev/prototype_space/check_layout.mjs | 29 ++++++++++++- dev/prototype_space/thumbnails.js | 61 ++++++++++++++++++++++------ 3 files changed, 90 insertions(+), 14 deletions(-) diff --git a/dev/prototype_space/README.md b/dev/prototype_space/README.md index 74d64ff9..43167fde 100644 --- a/dev/prototype_space/README.md +++ b/dev/prototype_space/README.md @@ -151,6 +151,20 @@ the work limit guarantees zero overlap or a global energy minimum. Viewport and movement bounds can make overlap unavoidable. Reduced-motion preferences run the same bounded solver without animation. +Numerical targets are separate from displayed thumbnail centers. Two cascaded +filters ease visible motion in and out, using elapsed seconds and CSS pixels: +a guide approaches the target at at most 140 px/s, and the displayed image +follows continuously with an 8/s response rate. This also bounds acceleration +(to 2240 px/s², including reversals). Substeps keep motion consistent across +refresh rates; background pauses advance at most 50 ms instead of catching up. +The animation continues after numerical convergence until displayed speed is +below 0.02 px/s and target error below 0.005 px, with no final snap. Image arrivals +retain the filter state; pan/zoom translates it with the anchor. Hit rectangles +and optional tethers follow displayed positions. Smoothing can leave temporary +contacts during motion; energy descent describes numerical targets, not every +displayed frame. `node dev/prototype_space/check_layout.mjs` checks full-motion +speed and acceleration, reversals, and 30/60/120 Hz agreement. + The status reports the remaining visible slots. Increase allowable overlap to try a denser candidate set; this does not guarantee that every admitted image can be separated within the diff --git a/dev/prototype_space/check_layout.mjs b/dev/prototype_space/check_layout.mjs index f000b7e2..b05c0951 100644 --- a/dev/prototype_space/check_layout.mjs +++ b/dev/prototype_space/check_layout.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import {readFileSync} from 'node:fs'; const source=readFileSync(new URL('./thumbnails.js',import.meta.url),'utf8'); -const {thumbnailEnergy,stepThumbnailForce}=Function(source.slice(source.indexOf('function thumbnailSharedArea'),source.indexOf('let mapThumbMotion'))+'return {thumbnailEnergy,stepThumbnailForce};')(); +const {thumbnailEnergy,stepThumbnailForce,stepThumbnailPresentation}=Function(source.slice(source.indexOf('function thumbnailSharedArea'),source.indexOf('let mapThumbMotion'))+'return {thumbnailEnergy,stepThumbnailForce,stepThumbnailPresentation};')(); const box=(id,x,y,width=64,height=64)=>({id,x,y,anchorX:x,anchorY:y,width,height}); const gradientCase=[box(0,100,100,72,60),box(1,149,127,64,80),box(2,210,150,50,64)]; gradientCase[0].anchorX-=12; @@ -35,3 +35,30 @@ for(const fixture of [ results.push({fixture:fixture.name,steps:state.steps,overlappingPairs:state.metrics.pairs}); } console.log(JSON.stringify({gradientChecks:6,fixtures:results})); + +// Measure the complete visible trajectory, including startup, a target reversal, +// and the deceleration tail, using real seconds at different display rates. +const trajectories=[]; +for(const hz of [30,60,120]){ + const boxes=[box(0,100,100)],target=[box(0,228,100)]; + let previousX=100,previousVelocity=0,peakSpeed=0,peakAcceleration=0,firstStep=0,result; + const samples=[]; + for(let frame=1;frame<=hz*5;frame++){ + if(frame===hz+1)target[0].x=68; + result=stepThumbnailPresentation(boxes,target,1/hz); + const velocity=(boxes[0].x-previousX)*hz; + peakSpeed=Math.max(peakSpeed,Math.abs(velocity)); + peakAcceleration=Math.max(peakAcceleration,Math.abs(velocity-previousVelocity)*hz); + if(frame===1)firstStep=boxes[0].x-100; + assert.ok(boxes[0].x>=68&&boxes[0].x<=228,'display remains inside feasible convex region'); + previousX=boxes[0].x;previousVelocity=velocity; + if(frame%(hz/10)===0)samples.push(boxes[0].x); + } + assert.ok(peakSpeed<=140.01,'visible speed bounded in CSS pixels per second'); + assert.ok(peakAcceleration<2240,'acceleration bounded even at target reversal'); + assert.ok(firstStep<.6,'startup must ease in instead of jumping a solver step'); + assert.ok(result.done&&result.speed<.02,'motion stops only after the slow tail'); + trajectories.push({hz,peakSpeed,peakAcceleration,firstStep,samples}); +} +for(let i=1;iMath.abs(x-trajectories[0].samples[j])<1e-8),'same elapsed time yields same presentation across refresh rates'); +console.log(JSON.stringify({trajectories:trajectories.map(({samples,...metrics})=>metrics)})); diff --git a/dev/prototype_space/thumbnails.js b/dev/prototype_space/thumbnails.js index 3a282db9..5752a2c3 100644 --- a/dev/prototype_space/thumbnails.js +++ b/dev/prototype_space/thumbnails.js @@ -65,6 +65,36 @@ function relaxThumbnails(boxes,width,height,maxShift){ const state={};while(!state.done)stepThumbnailForce(boxes,width,height,maxShift,state); return boxes; } +// Cascaded time-domain filters separate solver convergence from visible motion. +// A speed-limited guide and its continuous follower use CSS pixels and seconds. +// Convex combinations preserve viewport and anchor-radius bounds; retaining the +// guide on each box preserves velocity when loading or navigation reheats layout. +function stepThumbnailPresentation(boxes,targets,seconds){ + const omega=8,maxSpeed=140; + let remaining=Math.min(.05,Math.max(0,seconds)); + while(remaining>1e-9){ + const dt=Math.min(1/240,remaining);remaining-=dt; + boxes.forEach((b,i)=>{ + const target=targets[i];b.guideX??=b.x;b.guideY??=b.y; + const dx=target.x-b.guideX,dy=target.y-b.guideY,distance=Math.hypot(dx,dy); + const fraction=distance?Math.min(1-Math.exp(-omega*dt),maxSpeed*dt/distance):0; + const decay=Math.exp(-omega*dt); + for(const [key,guide] of [['x','guideX'],['y','guideY']]){ + const start=b[guide],velocity=(target[key]-start)*fraction/dt; + // Exact follower response to a linearly moving guide in this substep. + b[key]=start+velocity*dt-velocity/omega+(b[key]-start+velocity/omega)*decay; + b[guide]=start+velocity*dt; + } + }); + } + let speed=0,error=0; + boxes.forEach((b,i)=>{ + b.left=b.x-b.width/2;b.top=b.y-b.height/2; + speed=Math.max(speed,omega*Math.hypot((b.guideX??b.x)-b.x,(b.guideY??b.y)-b.y)); + error=Math.max(error,Math.hypot(targets[i].x-b.x,targets[i].y-b.y),Math.hypot(targets[i].x-(b.guideX??b.x),targets[i].y-(b.guideY??b.y))); + }); + return {speed,error,done:speed<.02&&error<.005}; +} let mapThumbMotion=null; function stopThumbnailMotion(){ if(!mapThumbMotion)return; @@ -87,25 +117,29 @@ let mapThumbRest=null; function animateThumbnails(config){ stopThumbnailMotion();mapThumbRest=null; return new Promise(resolve=>{ - const motion={frame:0,resolve,state:{},last:0};mapThumbMotion=motion; + const motion={frame:0,resolve,state:{},last:0,targets:mapThumbLayout.map(b=>({...b,vx:0,vy:0}))};mapThumbMotion=motion; const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches; const finish=()=>{ - for(const box of mapThumbLayout){box.vx=0;box.vy=0;} - mapThumbRest={...motion.state.metrics,reason:motion.state.reason,steps:motion.state.steps}; + const measured=thumbnailEnergy(mapThumbLayout); + mapThumbRest={...motion.state.metrics,energy:measured.energy,overlap:measured.overlap,pairs:measured.pairs,reason:motion.state.reason,steps:motion.state.steps,presentationSpeed:motion.presentation?.speed||0}; paintThumbnailMotion();mapThumbMotion=null;resolve(); }; - const advance=()=>{ - const active=mapThumbLayout.filter(b=>reduced||!b.readyAt||performance.now()>=b.readyAt); - if(active.length!==mapThumbLayout.length)return; - stepThumbnailForce(active,config.width,config.height,2*config.size,motion.state); - }; - if(reduced){while(!motion.state.done)advance();finish();return;} + const advance=()=>stepThumbnailForce(motion.targets,config.width,config.height,2*config.size,motion.state); + if(reduced){ + while(!motion.state.done)advance(); + mapThumbLayout.forEach((b,i)=>{b.x=motion.targets[i].x;b.y=motion.targets[i].y;b.guideX=b.x;b.guideY=b.y;b.left=b.x-b.width/2;b.top=b.y-b.height/2;}); + finish();return; + } const tick=time=>{ if(mapThumbMotion!==motion)return; - // Fixed 60 Hz presentation; at most two solver steps per frame and no - // catch-up burst after a background tab resumes. - if(!motion.last||time-motion.last>=15){advance();if(!motion.state.done)advance();motion.last=time;paintThumbnailMotion();} - if(motion.state.done){finish();return;} + const dt=motion.last?(time-motion.last)/1000:0;motion.last=time; + if(mapThumbLayout.every(b=>!b.readyAt||time>=b.readyAt)){ + for(let i=0;i<2&&!motion.state.done;i++)advance(); + } + // Existing images keep easing while newly decoded images start appearing. + motion.presentation=stepThumbnailPresentation(mapThumbLayout,motion.targets,dt); + paintThumbnailMotion(); + if(motion.state.done&&motion.presentation.done){finish();return;} motion.frame=requestAnimationFrame(tick); }; motion.frame=requestAnimationFrame(tick); @@ -159,6 +193,7 @@ function reanchorThumbnails(points,width,height){ for(const box of mapThumbLayout){ const point=points[box.id];if(!point)continue; const dx=box.x-box.anchorX,dy=box.y-box.anchorY; + if(box.guideX!==undefined){box.guideX+=point[0]-box.anchorX;box.guideY+=point[1]-box.anchorY;} box.anchorX=point[0];box.anchorY=point[1];box.x=point[0]+dx;box.y=point[1]+dy; box.left=box.x-box.width/2;box.top=box.y-box.height/2; if(box.left<0||box.top<0||box.left+box.width>width||box.top+box.height>height){box.button.remove();box.tether?.remove();continue;} From 7c695b8c954d7dc22c831a11e066b65b3912fd5a Mon Sep 17 00:00:00 2001 From: asgersvenning Date: Fri, 11 Sep 2026 00:16:03 +0200 Subject: [PATCH 15/34] fix: preserve thumbnail motion on arrival and fade culled images --- dev/prototype_space/README.md | 9 +++-- dev/prototype_space/check_browser.mjs | 9 +++++ dev/prototype_space/check_layout.mjs | 35 ++++++++++++++++++ dev/prototype_space/thumbnails.js | 52 ++++++++++++++++++++------- 4 files changed, 91 insertions(+), 14 deletions(-) diff --git a/dev/prototype_space/README.md b/dev/prototype_space/README.md index 43167fde..a9eb1d1e 100644 --- a/dev/prototype_space/README.md +++ b/dev/prototype_space/README.md @@ -159,7 +159,10 @@ follows continuously with an 8/s response rate. This also bounds acceleration refresh rates; background pauses advance at most 50 ms instead of catching up. The animation continues after numerical convergence until displayed speed is below 0.02 px/s and target error below 0.005 px, with no final snap. Image arrivals -retain the filter state; pan/zoom translates it with the anchor. Hit rectangles +join the existing frame loop and completion promise, retaining its clock, solver +destinations, and filter state. Each image waits independently for its appearance +delay before joining rectangle contacts; existing contacts keep advancing. +Targets follow class IDs even when admission order changes; pan/zoom translates it with the anchor. Hit rectangles and optional tethers follow displayed positions. Smoothing can leave temporary contacts during motion; energy descent describes numerical targets, not every displayed frame. `node dev/prototype_space/check_layout.mjs` checks full-motion @@ -169,7 +172,9 @@ The status reports the remaining visible slots. Increase allowable overlap to tr a denser candidate set; this does not guarantee that every admitted image can be separated within the movement bounds. Initial viewport/density/overlap admission remains separate -from settling; an admitted image is never removed merely because motion stops. +from settling; an admitted image is never removed merely because motion stops. Thumbnails culled +by viewport navigation fade out over 100 ms, with hit targets removed immediately. +Reduced-motion preferences remove them immediately. Culling prioritizes the selected class and its original-space neighbours, then uses a stable mixed class order. diff --git a/dev/prototype_space/check_browser.mjs b/dev/prototype_space/check_browser.mjs index 2e250720..a5f68596 100644 --- a/dev/prototype_space/check_browser.mjs +++ b/dev/prototype_space/check_browser.mjs @@ -158,6 +158,15 @@ try{ await new Promise(resolve=>setTimeout(resolve,300)); check(crowded.every(b=>b.button.isConnected),'no deferred settling-time removal'); for(const b of crowded){b.button.remove();b.tether.remove();}mapThumbLayout=savedLayout;paintThumbnailMotion(); + const fading={...savedLayout[0],id:-100,button:savedLayout[0].button.cloneNode(true),tether:null}; + document.getElementById('map-thumbnail-layer').append(fading.button); + // Exercise viewport culling with a valid, fully offscreen projected position. + mapThumbLayout=[{...fading,id:0}];reanchorThumbnails([[-1000,-1000]],motionConfig.width,motionConfig.height); + check(mapThumbLayout.length===0&&fading.button.isConnected&&fading.button.disabled&&fading.button.style.pointerEvents==='none','viewport culling removes hit targets immediately while keeping the exit visible'); + check(fading.button.getAnimations().some(a=>a.effect.getTiming().duration===100),'culled thumbnail gets a fast exit animation'); + await new Promise(resolve=>setTimeout(resolve,180)); + check(!fading.button.isConnected,'culled thumbnail is removed after fading'); + mapThumbLayout=savedLayout;paintThumbnailMotion(); const cancelled=animateThumbnails(motionConfig);stopThumbnailMotion();await cancelled; check(mapThumbMotion===null,'force animation cancellation resolves pending work'); document.getElementById('map-photos').checked=false;scheduleMapThumbnails();document.getElementById('map-photos').checked=true;document.getElementById('map-photo-density').value='5';mapThumbImages.clear(); diff --git a/dev/prototype_space/check_layout.mjs b/dev/prototype_space/check_layout.mjs index b05c0951..9bfceb20 100644 --- a/dev/prototype_space/check_layout.mjs +++ b/dev/prototype_space/check_layout.mjs @@ -62,3 +62,38 @@ for(const hz of [30,60,120]){ } for(let i=1;iMath.abs(x-trajectories[0].samples[j])<1e-8),'same elapsed time yields same presentation across refresh rates'); console.log(JSON.stringify({trajectories:trajectories.map(({samples,...metrics})=>metrics)})); + +// Drive the real animation coordinator with deterministic browser frames. An +// arriving image must not cancel a frame, rewind destinations, or pause others. +let queued=null,clock=0,cancellations=0; +const coordinator=Function('requestAnimationFrame','cancelAnimationFrame','matchMedia', + source.slice(0,source.indexOf('function thumbnailFootprint'))+` + paintThumbnailMotion=()=>{}; + return {set:boxes=>{mapThumbLayout=boxes;},start:animateThumbnails, + motion:()=>mapThumbMotion,stop:stopThumbnailMotion}; +`)(callback=>{queued=callback;return 1;},()=>{queued=null;cancellations++;},()=>({matches:false})); +const actors=[box(0,200,200),box(1,220,200)];coordinator.set(actors); +const config={width:600,height:400,size:64}; +const running=coordinator.start(config); +const frame=()=>{clock+=1000/60;const callback=queued;queued=null;callback(clock);}; +for(let i=0;i<6;i++)frame(); +const original=coordinator.motion(),destination=original.byId.get(0),lastTime=original.last; +const newcomer=box(2,500,300);newcomer.readyAt=clock+100;actors.unshift(newcomer); +assert.equal(coordinator.start(config),running,'arrivals share the running completion promise'); +assert.equal(coordinator.motion(),original,'arrival preserves the animation loop'); +assert.equal(original.last,lastTime,'arrival does not reset frame time'); +const beforeArrival=actors[1].x,steps=original.state.steps; +for(let i=0;i<4;i++)frame(); +assert.equal(cancellations,0,'image appearance must not cancel frames'); +assert.equal(original.byId.get(0),destination,'existing numerical targets survive arrival and reordering'); +assert.ok(original.state.steps>steps,'existing contacts advance while a newcomer waits'); +assert.ok(Math.abs(actors[1].x-beforeArrival)>.5,'existing image keeps moving during appearance delay'); +assert.equal(newcomer.x,500,'new image stays still until its own appearance delay'); +assert.ok(!original.byId.has(2),'waiting image exerts no collision force'); +for(let i=0;i<4;i++)frame(); +assert.ok(original.byId.has(2),'new image joins the same solver after appearing'); +assert.equal(original.byId.get(0),destination,'joining contacts preserve existing destinations'); +for(let i=0;i<600&&coordinator.motion();i++)frame(); +assert.equal(coordinator.motion(),null,'joined animation eventually settles'); +await running; +console.log(JSON.stringify({arrivalContinuityChecks:12})); diff --git a/dev/prototype_space/thumbnails.js b/dev/prototype_space/thumbnails.js index 5752a2c3..d3e8b214 100644 --- a/dev/prototype_space/thumbnails.js +++ b/dev/prototype_space/thumbnails.js @@ -115,17 +115,31 @@ function paintThumbnailMotion(){ } let mapThumbRest=null; function animateThumbnails(config){ - stopThumbnailMotion();mapThumbRest=null; - return new Promise(resolve=>{ - const motion={frame:0,resolve,state:{},last:0,targets:mapThumbLayout.map(b=>({...b,vx:0,vy:0}))};mapThumbMotion=motion; + // Image arrivals join the existing frame loop; they never reset its clock, + // numerical destinations, or presentation velocity. + if(mapThumbMotion)return mapThumbMotion.promise; + mapThumbRest=null; + const motion={frame:0,state:{},last:0,targets:[],byId:new Map()}; + mapThumbMotion=motion; + return motion.promise=new Promise(resolve=>{ + motion.resolve=resolve; const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches; const finish=()=>{ const measured=thumbnailEnergy(mapThumbLayout); mapThumbRest={...motion.state.metrics,energy:measured.energy,overlap:measured.overlap,pairs:measured.pairs,reason:motion.state.reason,steps:motion.state.steps,presentationSpeed:motion.presentation?.speed||0}; paintThumbnailMotion();mapThumbMotion=null;resolve(); }; + const syncTargets=time=>{ + const active=mapThumbLayout.filter(b=>reduced||!b.readyAt||time>=b.readyAt); + const changed=active.length!==motion.byId.size||active.some(b=>!motion.byId.has(b.id)); + motion.targets=active.map(b=>motion.byId.get(b.id)||({...b,vx:0,vy:0})); + motion.byId=new Map(motion.targets.map(b=>[b.id,b])); + if(changed)motion.state={}; + return active.length===mapThumbLayout.length; + }; const advance=()=>stepThumbnailForce(motion.targets,config.width,config.height,2*config.size,motion.state); if(reduced){ + syncTargets(Infinity); while(!motion.state.done)advance(); mapThumbLayout.forEach((b,i)=>{b.x=motion.targets[i].x;b.y=motion.targets[i].y;b.guideX=b.x;b.guideY=b.y;b.left=b.x-b.width/2;b.top=b.y-b.height/2;}); finish();return; @@ -133,13 +147,14 @@ function animateThumbnails(config){ const tick=time=>{ if(mapThumbMotion!==motion)return; const dt=motion.last?(time-motion.last)/1000:0;motion.last=time; - if(mapThumbLayout.every(b=>!b.readyAt||time>=b.readyAt)){ - for(let i=0;i<2&&!motion.state.done;i++)advance(); - } - // Existing images keep easing while newly decoded images start appearing. - motion.presentation=stepThumbnailPresentation(mapThumbLayout,motion.targets,dt); + const allReady=syncTargets(time); + for(let i=0;i<2&&!motion.state.done;i++)advance(); + // Each new image waits independently; existing contacts and destinations + // continue evolving while it fades in. Match by ID, not admission order. + const targets=mapThumbLayout.map(b=>motion.byId.get(b.id)||b); + motion.presentation=stepThumbnailPresentation(mapThumbLayout,targets,dt); paintThumbnailMotion(); - if(motion.state.done&&motion.presentation.done){finish();return;} + if(allReady&&motion.state.done&&motion.presentation.done){finish();return;} motion.frame=requestAnimationFrame(tick); }; motion.frame=requestAnimationFrame(tick); @@ -188,15 +203,28 @@ function showMapPhotoCredit(index){ node.append(photoText('span',`${album.display_name} · class ${id} · ${photo.creator} · ${photo.license} · `),photoLink('Image source',photo.source),photoText('span',' · '),photoLink('GBIF record','https://www.gbif.org/occurrence/'+photo.occurrence_id)); } let mapThumbCase=null,mapThumbKey='',mapThumbOffsets=new Map(); +function retireThumbnail(box){ + // Remove interaction immediately; the short visual exit is independent of + // solver membership and cannot delay newly visible thumbnails. + box.button.disabled=true;box.button.style.pointerEvents='none'; + box.button.setAttribute('aria-hidden','true'); + for(const node of [box.button,box.tether]){ + if(!node)continue; + if(matchMedia('(prefers-reduced-motion: reduce)').matches){node.remove();continue;} + const opacity=getComputedStyle(node).opacity; + const fade=node.animate([{opacity},{opacity:0}],{duration:100,fill:'forwards'}); + fade.onfinish=()=>node.remove(); + } +} function reanchorThumbnails(points,width,height){ const kept=[]; for(const box of mapThumbLayout){ - const point=points[box.id];if(!point)continue; + const point=points[box.id];if(!point){retireThumbnail(box);continue;} const dx=box.x-box.anchorX,dy=box.y-box.anchorY; if(box.guideX!==undefined){box.guideX+=point[0]-box.anchorX;box.guideY+=point[1]-box.anchorY;} box.anchorX=point[0];box.anchorY=point[1];box.x=point[0]+dx;box.y=point[1]+dy; box.left=box.x-box.width/2;box.top=box.y-box.height/2; - if(box.left<0||box.top<0||box.left+box.width>width||box.top+box.height>height){box.button.remove();box.tether?.remove();continue;} + if(box.left<0||box.top<0||box.left+box.width>width||box.top+box.height>height){retireThumbnail(box);continue;} kept.push(box); } mapThumbLayout=kept;paintThumbnailMotion(); @@ -236,7 +264,7 @@ async function renderMapThumbnails(){ const retained=new Map(); for(const box of mapThumbLayout){ if(ranks.has(box.id)&&box.photoChoice===(photoChoices.get(data.names[box.id])||0))retained.set(box.id,box); - else{box.button.remove();box.tether?.remove();} + else{retireThumbnail(box);} } mapThumbLayout=[...retained.values()].sort((a,b)=>ranks.get(a.id)-ranks.get(b.id)); From 4763041caedf4e51d7353e06f5a0c7d19f3aed08 Mon Sep 17 00:00:00 2001 From: asgersvenning Date: Fri, 11 Sep 2026 00:27:24 +0200 Subject: [PATCH 16/34] feat: package prototype explorer with browser weight selection --- README.md | 7 + dev/prototype_space/README.md | 25 +- dev/prototype_space/check_launcher.mjs | 47 +++ dev/prototype_space/explore.py | 274 +-------------- dev/prototype_space/photos.js | 141 +------- dev/prototype_space/projection.js | 74 +--- dev/prototype_space/projection.py | 113 +----- dev/prototype_space/report.html | 138 +------- dev/prototype_space/serve.py | 198 +---------- dev/prototype_space/thumbnails.js | 325 +----------------- dev/wheel_smoke.py | 8 + docs/prototype-explorer.md | 100 ++++++ .../visualization/prototype_space/__init__.py | 1 + .../visualization/prototype_space/__main__.py | 5 + .../visualization/prototype_space/explore.py | 283 +++++++++++++++ .../visualization/prototype_space/launch.py | 209 +++++++++++ .../prototype_space/launcher.html | 14 + .../visualization/prototype_space/photos.js | 140 ++++++++ .../prototype_space/projection.js | 73 ++++ .../prototype_space/projection.py | 112 ++++++ .../visualization/prototype_space/report.html | 143 ++++++++ .../visualization/prototype_space/serve.py | 199 +++++++++++ .../prototype_space/thumbnails.js | 324 +++++++++++++++++ pyproject.toml | 3 + tests/utils/test_prototype_launcher.py | 99 ++++++ uv.lock | 100 +++++- 26 files changed, 1902 insertions(+), 1253 deletions(-) create mode 100644 dev/prototype_space/check_launcher.mjs mode change 100644 => 120000 dev/prototype_space/photos.js mode change 100644 => 120000 dev/prototype_space/projection.js mode change 100644 => 120000 dev/prototype_space/report.html mode change 100644 => 120000 dev/prototype_space/thumbnails.js create mode 100644 docs/prototype-explorer.md create mode 100644 mini_trainer/visualization/prototype_space/__init__.py create mode 100644 mini_trainer/visualization/prototype_space/__main__.py create mode 100644 mini_trainer/visualization/prototype_space/explore.py create mode 100644 mini_trainer/visualization/prototype_space/launch.py create mode 100644 mini_trainer/visualization/prototype_space/launcher.html create mode 100644 mini_trainer/visualization/prototype_space/photos.js create mode 100644 mini_trainer/visualization/prototype_space/projection.js create mode 100644 mini_trainer/visualization/prototype_space/projection.py create mode 100644 mini_trainer/visualization/prototype_space/report.html create mode 100644 mini_trainer/visualization/prototype_space/serve.py create mode 100644 mini_trainer/visualization/prototype_space/thumbnails.js create mode 100644 tests/utils/test_prototype_launcher.py diff --git a/README.md b/README.md index 6f16c8d5..a9ca7659 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,13 @@ All code in `mini_trainer` should follow the following core principles: * All hyperparameters and system configuration should have smart defaults that are as general as possible * All functionality should be extendable to custom model architectures, loss functions, training regimes, data formats etc. +## Explore model prototypes + +With the optional `explorer` dependencies installed, run `mt_explore weights.pt` +to open the interactive prototype explorer, or `mt_explore` to choose weights in +the browser. See the [prototype explorer guide](docs/prototype-explorer.md) for +installation, portable exports, supported checkpoints, and interpretation. + # Installation We recommend using `uv` for package and environment management. diff --git a/dev/prototype_space/README.md b/dev/prototype_space/README.md index a9eb1d1e..da55bfd2 100644 --- a/dev/prototype_space/README.md +++ b/dev/prototype_space/README.md @@ -1,6 +1,9 @@ # Prototype-space exploration -This is an offline interactive feature prototype. The accompanying +The reusable tool is installed as `mt_explore`; see the +[launcher and export guide](../../docs/prototype-explorer.md). Runtime code and +viewer assets live in `mini_trainer.visualization.prototype_space`. These developer +commands remain as compatibility entry points and regression tools. The accompanying [log-domain diagnostic API](../../docs/prototype-diagnostics.md) also supports evaluation logging during training. The existing weight parametrization, similarity transform and class distance remain the empirical reference. @@ -296,3 +299,23 @@ CHROMIUM_BIN=/absolute/path/to/chrome node dev/prototype_space/check_browser.mjs No browser dependency is installed by this helper. Screenshots and the interaction check report stay in the ignored output directory. + +## Packaged launcher checks + +`tests/utils/test_prototype_launcher.py` checks portable generation, class order, +HTTP upload, invalid-file recovery, and separation of weights from served files. +`dev/check-wheel.sh` checks the installed entry point, bundled assets, and the +missing-extra message in a minimal environment. + +For the browser file-picker path, start `mt_explore --no-browser` (or its module +equivalent) and run: + +```bash +CHROMIUM_BIN=/path/to/chrome node dev/prototype_space/check_launcher.mjs \ + http://localhost:PORT/ tmp/launcher-check /absolute/path/to/small-weights.pt +``` + +This selects the file with Chromium's native input, generates the default angular +map, opens the report, and returns to the picker. Use a small supported checkpoint +for this bounded regression check. The original `check_browser.mjs` exercises the +full real-checkpoint report with its three synthetic comparison cases. diff --git a/dev/prototype_space/check_launcher.mjs b/dev/prototype_space/check_launcher.mjs new file mode 100644 index 00000000..1637df6f --- /dev/null +++ b/dev/prototype_space/check_launcher.mjs @@ -0,0 +1,47 @@ +// Optional browser smoke check. Set CHROMIUM_BIN; pass a report URL/path and output directory. +import {spawn} from 'node:child_process'; +import {writeFileSync,mkdtempSync,rmSync,mkdirSync,existsSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {pathToFileURL} from 'node:url'; +import {resolve,join} from 'node:path'; +const executable=process.env.CHROMIUM_BIN; +if(!executable||!existsSync(executable)||!process.argv[2])throw Error('Set CHROMIUM_BIN and pass report URL/path [output directory]'); +const output=resolve(process.argv[3]||'tmp/prototype-browser-check');mkdirSync(output,{recursive:true}); +const profile=mkdtempSync(tmpdir()+'/prototype-browser-'); +const chrome=spawn(executable,['--headless','--no-sandbox','--disable-gpu','--disable-dev-shm-usage','--no-first-run','--no-default-browser-check','--disable-background-networking','--disable-component-update','--disable-extensions','--remote-debugging-pipe','--user-data-dir='+profile],{stdio:['ignore','ignore','ignore','pipe','pipe']}); +let sequence=0,buffer=Buffer.alloc(0);const pending=new Map();const errors=[]; +chrome.stdio[4].on('data',chunk=>{buffer=Buffer.concat([buffer,chunk]);let end;while((end=buffer.indexOf(0))!==-1){const msg=JSON.parse(buffer.subarray(0,end));buffer=buffer.subarray(end+1);if(msg.id){const cb=pending.get(msg.id);if(cb){pending.delete(msg.id);msg.error?cb.reject(Error(JSON.stringify(msg.error))):cb.resolve(msg.result);}}else if(msg.method==='Runtime.exceptionThrown')errors.push(msg.params.exceptionDetails);}}); +function send(method,params={},sessionId){const id=++sequence;return new Promise((resolve,reject)=>{pending.set(id,{resolve,reject});chrome.stdio[3].write(JSON.stringify({id,method,params,...(sessionId?{sessionId}:{})})+'\0');});} +const deadline=setTimeout(()=>{console.error('Browser smoke timed out');chrome.kill('SIGTERM');process.exitCode=1;},60000); +try{ + const {targetId}=await send('Target.createTarget',{url:'about:blank'});const {sessionId}=await send('Target.attachToTarget',{targetId,flatten:true}); + const call=(m,p)=>send(m,p,sessionId);await call('Page.enable');await call('Runtime.enable'); + await call('Emulation.setDeviceMetricsOverride',{width:1440,height:1000,deviceScaleFactor:1,mobile:false}); + await call('Page.navigate',{url:process.argv[2]}); + const evaluate=async expression=>{const result=await call('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true});if(result.exceptionDetails)throw Error(JSON.stringify(result.exceptionDetails));return result.result.value;}; + for(let i=0;i<100;i++){if(await evaluate('!!document.getElementById("weights")'))break;await new Promise(r=>setTimeout(r,100));} + const {root}=await call('DOM.getDocument');const {nodeId}=await call('DOM.querySelector',{nodeId:root.nodeId,selector:'#weights'}); + await call('DOM.setFileInputFiles',{nodeId,files:[resolve(process.argv[4])]}); + await evaluate('document.getElementById("open").requestSubmit()'); + let ready=false; + for(let i=0;i<400;i++){ + if(await evaluate('!document.getElementById("view").hidden')){ready=true;break;} + const text=await evaluate('document.getElementById("status").textContent');if(text.startsWith('Could not'))throw Error(text); + await new Promise(r=>setTimeout(r,100)); + } + if(!ready)throw Error('File picker did not finish generation'); + await evaluate('document.getElementById("view").click()'); + for(let i=0;i<100;i++){if(await evaluate('!!document.getElementById("projection-map")'))break;await new Promise(r=>setTimeout(r,100));} + const result=await evaluate(`(async()=>{ + for(let i=0;i<50&&document.getElementById('open-model').hidden;i++)await new Promise(r=>setTimeout(r,50)); + return {classes:data.names.length,names:data.names,checkpoint:data.metadata.checkpoint,projection:document.getElementById('projection-plane').value,canOpenAnother:!document.getElementById('open-model').hidden,saveLink:!!document.querySelector('a[download]')}; + })()`); + if(!result.canOpenAnother||!result.saveLink||result.classes<2||result.projection!=='Angular t-SNE')throw Error(JSON.stringify(result)); + const shot=await call('Page.captureScreenshot',{format:'png'});writeFileSync(join(output,'uploaded-model.png'),Buffer.from(shot.data,'base64')); + await evaluate('document.getElementById("open-model").click()'); + for(let i=0;i<50;i++){if(await evaluate('!!document.getElementById("weights")'))break;await new Promise(r=>setTimeout(r,100));} + if(!await evaluate('!!document.getElementById("weights")'))throw Error('Cannot return to file picker'); + if(errors.length)throw Error(JSON.stringify(errors)); + console.log(JSON.stringify({browserUpload:result,returnedToPicker:true,javascriptErrors:errors.length})); + await send('Browser.close'); +}catch(e){console.error(e);process.exitCode=1;chrome.kill('SIGTERM');}finally{clearTimeout(deadline);setTimeout(()=>rmSync(profile,{recursive:true,force:true}),1000);} diff --git a/dev/prototype_space/explore.py b/dev/prototype_space/explore.py index 27f52b62..3d44425b 100644 --- a/dev/prototype_space/explore.py +++ b/dev/prototype_space/explore.py @@ -1,275 +1,7 @@ -"""Offline prototype exploration using mini_trainer's existing diagnostic APIs.""" - -import argparse -import base64 -import hashlib -import json -import math -from pathlib import Path - -import numpy as np -import torch -from matplotlib import colormaps -from scipy.cluster.hierarchy import linkage -from scipy.spatial.distance import squareform -from torch import nn - -from mini_trainer.modeling import Classifier, class_distance, class_log_similarity, class_similarity -from mini_trainer.visualization._dendrogram_layout import linkage_layout - -from .projection import angular_tsne, prototype_projections - - -def weight_model(weight: torch.Tensor) -> nn.Module: - """A linear-only diagnostic head: values are already effective weights.""" - with torch.device("meta"): - head = Classifier(weight.shape[1], weight.shape[0], hidden=False, normalized=False) - head.linear = nn.Linear(weight.shape[1], weight.shape[0], bias=False, device="meta") - head.linear.load_state_dict({"weight": weight}, assign=True) - return nn.Sequential(head) - - -def load_prototypes(path: Path): - """Read supported floating-point linear heads without building a backbone. - - This is deliberately not a general checkpoint loader. Reject other head - families instead of treating a sublayer as the complete prototype space. - """ - state = torch.load(path, map_location="cpu", weights_only=True) - metas = [(key, value) for key, value in state.items() if key.endswith("._extra_state") and isinstance(value, dict)] - if len(metas) != 1: - raise ValueError("Expected exactly one classifier metadata record") - key, meta = metas[0] - if meta.get("classifier_class") not in { - "mini_trainer.modeling.classifier:Classifier", - "mini_trainer.hierarchical.model:HierarchicalClassifier", - }: - raise ValueError("Explorer supports only Classifier and HierarchicalClassifier linear prototypes") - prefix = key.removesuffix("_extra_state") + "linear." - linear = {key[len(prefix) :]: value for key, value in state.items() if key.startswith(prefix)} - normalized = meta.get("normalized") - weight_key = "parametrizations.weight.original1" if normalized else "weight" - raw = linear.get(weight_key) - if not isinstance(raw, torch.Tensor) or raw.ndim != 2 or raw.dtype != torch.float32: - raise ValueError("Expected float32 linear weights; quantized/other checkpoint encodings are not supported") - layer = nn.Linear(raw.shape[1], raw.shape[0], bias="bias" in linear) - if normalized: - layer = Classifier._normalize_layer(layer, orthogonal_init=False) - layer.load_state_dict(linear, strict=True) - weight = layer.weight.detach().clone() - if not torch.isfinite(weight).all() or not (weight.norm(dim=1) > 0).all(): - raise ValueError("Prototype rows must be finite and nonzero") - mapping = meta["cls2idx"] - if all(isinstance(value, dict) for value in mapping.values()): - mapping = mapping.get("0", mapping.get(0)) - if not mapping or sorted(mapping.values()) != list(range(len(weight))): - raise ValueError("Class mapping must cover every prototype exactly once") - names = [""] * len(weight) - for name, index in mapping.items(): - names[index] = str(name) - hierarchy = meta.get("labels", {}) - groups = [list(map(str, hierarchy.get(name, [name]))) for name in names] - with path.open("rb") as stream: - digest = hashlib.file_digest(stream, "sha256").hexdigest() - return ( - weight, - names, - groups, - { - "checkpoint_sha256": digest, - "checkpoint": path.name, - "classifier": meta["classifier_class"], - "normalized": normalized, - "norm_range": [weight.norm(dim=1).min().item(), weight.norm(dim=1).max().item()], - "bias_range": [layer.bias.min().item(), layer.bias.max().item()] if layer.bias is not None else None, - "analysis_script_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), - "torch_version": str(torch.__version__), - "numpy_version": np.__version__, - }, - ) - - -def array_payload(array, dtype="=2 classes, >2 dimensions and >=1 neighbour") - model = weight_model(weight) - print(f"Computing repository z-scores and distances for {n:,} × {dim:,}", flush=True) - z = class_similarity(model, cdf=False)[0].numpy() - distance = class_distance(model)[0].numpy() - tail = 1 - class_similarity(model, cdf=True)[0].numpy() - log_tail = class_log_similarity(model, complement=True)[0].numpy() - assert np.isfinite(distance).all() and np.isfinite(z).all() - k = min(neighbours, n - 1) - # z is a separately labelled refinement of saturated distance ties. - # Stable sorting uses checkpoint class order for exactly equal z values. - near = np.empty((n, k), dtype=np.int32) - for i in range(n): - row = z[i].copy() - row[i] = -np.inf - near[i] = np.argsort(-row, kind="stable")[:k] - ids = np.column_stack((np.arange(n), near)) - local_d = distance[ids[:, :, None], ids[:, None, :]] - local_z = z[ids[:, :, None], ids[:, None, :]] - stable_near, _ = stable_scores(z[np.arange(n)[:, None], near]) - local_logtail = log_tail[ids[:, :, None], ids[:, None, :]] / math.log(10) - zero_count = (distance == 0).sum(axis=1) - 1 - recovered = 0 - for row in range(n): - zero_ids = np.flatnonzero(distance[row] == 0) - zero_ids = zero_ids[zero_ids > row] - stable_zero, _ = stable_scores(z[row, zero_ids]) - recovered += int((stable_zero > 0).sum()) - upper = np.triu_indices(n, 1) if n < 1024 else None - rng = np.random.default_rng(seed) - if upper is None: - i, j = rng.integers(n, size=(2, 200_000)) - i, j = i[i != j], j[i != j] - else: - i, j = upper - sampled = z[i, j] - robust_range = np.quantile(log_tail[i, j] / math.log(10), [0.001, 0.999]).tolist() - bins = np.linspace(-6, max(8, float(sampled.max()) + 0.1), 100) - hist, edges = np.histogram(sampled, bins) - stats = { - "classes": n, - "dimensions": dim, - "off_diagonal_zero_distance_pairs": int(zero_count.sum() // 2), - "classes_with_zero_distance_neighbours": int((zero_count > 0).sum()), - "zero_pairs_resolved_by_float64_logcdf": recovered, - "finite_off_diagonal_log_tail_pairs": int(np.isfinite(log_tail).sum() // 2), - "pair_sample_count": len(sampled), - "seed": seed, - "pair_sampling": "all unordered pairs" if upper is not None else "uniform ordered pairs with replacement, diagonal excluded", - "pair_z_mean": float(sampled.mean()), - "pair_z_std": float(sampled.std()), - "pair_z_quantiles": dict(zip(["1%", "50%", "99%", "max_sampled"], np.quantile(sampled, [0.01, 0.5, 0.99, 1]).tolist())), - "nearest_z_median": float(np.median(z[np.arange(n), near[:, 0]])), - "kth_z_median": float(np.median(z[np.arange(n), near[:, -1]])), - "k": k, - "histogram_excluded": int(len(sampled) - hist.sum()), - } - print(json.dumps(stats), flush=True) - print("Building unchanged Ward linkage on class_distance", flush=True) - tree = linkage(squareform(distance, checks=False), method="ward") - order, _, _, _ = linkage_layout(tree, np.ones(n, dtype=int)) - order = np.asarray(order) - low, high, block = block_extrema(tail, order) - log_low, log_high, _ = block_extrema(log_tail, order) - log_floor = math.floor(float(np.nanmin(log_low)) / math.log(10)) * math.log(10) - del distance, tail - ranks = [1, min(5, n - 1), min(12, n - 1), min(32, n - 1)] - profiles = np.empty((n, len(ranks)), dtype=np.float32) - for i in range(n): - row = z[i].copy() - row[i] = -np.inf - profiles[i] = -np.partition(-row, np.asarray(ranks) - 1)[np.asarray(ranks) - 1] - projections = prototype_projections(weight, near.tolist()) - if include_tsne: - print("Fitting angular t-SNE", flush=True) - projections["Angular t-SNE"] = angular_tsne(z, dim, near.tolist(), seed) - return { - "projections": projections, - "metadata": metadata or {}, - "stats": stats, - "names": names, - "groups": groups, - "neighbours": near.tolist(), - "local_distance": array_payload(local_d), - "local_z": array_payload(local_z), - "local_log10_tail": array_payload(local_logtail), - "stable_neighbour_distance": array_payload(stable_near.numpy(), dtype=" /^\d+$/.test(id); - -function photoText(tag, text, className = '') { - const node = document.createElement(tag); - node.textContent = text; - node.className = className; - return node; -} - -function photoLink(text, href) { - const node = photoText('a', text); - node.href = href; - node.target = '_blank'; - node.rel = 'noopener noreferrer'; - return node; -} - -function loadReferenceImage(image, src, signal) { - return new Promise(resolve => { - let timer; - const done = ok => { - clearTimeout(timer); - image.onload = image.onerror = null; - signal.removeEventListener('abort', abort); - resolve(ok); - }; - const abort = () => { image.removeAttribute('src'); done(false); }; - if (signal.aborted) { done(false); return; } - signal.addEventListener('abort', abort, {once: true}); - image.onload = () => done(true); - image.onerror = () => done(false); - timer = setTimeout(abort, 18000); - // Keep the image visible during loading so progressive decoding can appear. - image.src = src; - }); -} - -async function showReference(card, album, classIndex, generation, signal) { - const photos = album.photos; - card.title.textContent = album.display_name; - if (!photos.length) { card.status.textContent = 'No matching class examples in the first 20 GBIF records.'; return; } - const choice = (photoChoices.get(album.class_id) || 0) % photos.length; - const photo = photos[choice]; - card.credit.replaceChildren( - photoText('span', photo.creator + ' · ' + photo.license + ' '), - photoLink('Source', photo.source), - photoText('span', ' · '), - photoLink('GBIF record', 'https://www.gbif.org/occurrence/' + photo.occurrence_id) - ); - card.image.alt = `GBIF example of ${album.display_name}; class ${album.class_id}`; - card.status.textContent = `Loading example ${choice + 1} / ${photos.length}…`; - card.next.hidden = photos.length < 2; - card.next.disabled = true; - card.next.onclick = async () => { - if (generation !== photoGeneration) return; - photoChoices.set(album.class_id, choice + 1); - card.next.disabled = true; - await showReference(card, album, classIndex, generation, signal); - if(typeof scheduleMapThumbnails==='function')scheduleMapThumbnails(); - card.next.disabled = false; - }; - const loaded = await loadReferenceImage(card.image, photo.image_path, signal); - if (generation !== photoGeneration) return; - card.next.disabled = false; - card.status.textContent = loaded ? `Example ${choice + 1} / ${photos.length}${album.accepted_id !== album.class_id ? ' · GBIF accepted ID ' + album.accepted_id : ''}` : 'Image unavailable or timed out. Try another example.'; -} - -async function renderPhotos() { - const generation = ++photoGeneration; - photoController?.abort(); - photoController = new AbortController(); - const signal = photoController.signal; - const grid = $('photo-grid'); - grid.replaceChildren(); - if (!$('photo-enabled').checked) { - $('photo-status').textContent = 'Enable to browse the selected class and its nearest directions using GBIF class examples.'; - return; - } - const ids = [selected, ...data.neighbours[selected]]; - if (data.metadata.synthetic || !ids.some(i => photoNumber(data.names[i]))) { - $('photo-status').textContent = 'This case has no GBIF-ID candidates. No online lookup was made.'; - return; - } - $('photo-status').textContent = 'Loading class examples sequentially; class IDs and numerical neighbourhoods stay unchanged.'; - try { - const health = await fetch('/api/health', {signal}); - if (!health.ok || !(await health.json()).photo_api) throw Error('Photo service unavailable'); - } catch (error) { - if (signal.aborted) return; - $('photo-status').textContent = 'Photo view needs the local photo server: .venv/bin/python -m dev.prototype_space.serve --directory tmp/prototype-report. Open its localhost URL.'; - return; - } - const cards = ids.map((classIndex, rank) => { - const card = photoText('article', '', 'photo-card' + (rank === 0 ? ' photo-selected' : '')); - const open = photoText('button', '', 'photo-open'); - open.type = 'button'; - open.setAttribute('aria-label', `Explore class ${data.names[classIndex]}`); - const image = document.createElement('img'); - image.decoding = 'async'; - open.append(image); - open.onclick = () => selectClass(classIndex); - const title = photoText('strong', data.names[classIndex], 'photo-title'); - const identity = photoText('span', `${rank === 0 ? 'Selected' : 'Neighbour ' + rank} · class ${data.names[classIndex]}`, 'caption'); - const status = photoText('p', 'Waiting…', 'caption'); - const next = photoText('button', 'Another example'); - next.type = 'button'; next.hidden = true; - const credit = photoText('p', '', 'photo-credit'); - card.append(open, title, identity, status, next, credit); - grid.append(card); - return {image, title, status, next, credit}; - }); - // Only fetch the visible neighbourhood. Cache metadata in-session and images - // on the local server; do not download one image for every checkpoint class. - for (let rank = 0; rank < ids.length; rank++) { - if (signal.aborted || generation !== photoGeneration) return; - const classIndex = ids[rank], id = data.names[classIndex], card = cards[rank]; - if (!photoNumber(id)) {card.status.textContent = 'Not a GBIF ID.'; continue;} - try { - let album = photoAlbums.get(id); - if (!album) { - const response = await fetch('/api/gbif/' + id, {signal}); - album = await response.json(); - if (!response.ok) throw Error(album.error || 'GBIF lookup failed'); - photoAlbums.set(id, album); - } - if (signal.aborted || generation !== photoGeneration) return; - await showReference(card, album, classIndex, generation, signal); - } catch (error) { - if (!signal.aborted) card.status.textContent = 'Example lookup unavailable. Toggle photos to retry.'; - } - } - if (generation === photoGeneration) $('photo-status').textContent = 'Click a photo to explore that class’s neighbours. Images label classes at their learned prototype directions.'; -} -$('photo-enabled').onchange = renderPhotos; -renderPhotos(); diff --git a/dev/prototype_space/photos.js b/dev/prototype_space/photos.js new file mode 120000 index 00000000..9cdaf372 --- /dev/null +++ b/dev/prototype_space/photos.js @@ -0,0 +1 @@ +../../mini_trainer/visualization/prototype_space/photos.js \ No newline at end of file diff --git a/dev/prototype_space/projection.js b/dev/prototype_space/projection.js deleted file mode 100644 index d4b1f383..00000000 --- a/dev/prototype_space/projection.js +++ /dev/null @@ -1,73 +0,0 @@ -/* Coordinates are fitted offline. This canvas changes only the viewing transform. */ -let projectionCase = null; -let projectionView = {x:0, y:0, scale:1}; -let projectionDrag = null; -let projectionHits = []; -const projectionCanvas = $('projection-map'); -function currentProjection() { return data.projections?.[$('projection-plane').value]; } -function projectionPoint(point) {return [600+(point[0]-projectionView.x)*projectionView.scale,300-(point[1]-projectionView.y)*projectionView.scale];} -function fitProjection(ids) { - const p=currentProjection();if(!p)return; - const points=ids.map(i=>p.coordinates[i]); - let x0=Infinity,x1=-Infinity,y0=Infinity,y1=-Infinity; - for(const [x,y] of points){x0=Math.min(x0,x);x1=Math.max(x1,x);y0=Math.min(y0,y);y1=Math.max(y1,y);} - projectionView={x:(x0+x1)/2,y:(y0+y1)/2,scale:Math.min(1050/Math.max(.0001,x1-x0),450/Math.max(.0001,y1-y0))}; -} -function drawProjection() { - const ctx=projectionCanvas.getContext('2d');ctx.clearRect(0,0,1200,600); - if(projectionCase!==data){ - projectionCase=data;$('projection-plane').replaceChildren(); - for(const name of Object.keys(data.projections||{})){const option=document.createElement('option');option.value=option.textContent=name;$('projection-plane').append(option);} - if(data.projections?.['Angular t-SNE'])$('projection-plane').value='Angular t-SNE'; - fitProjection(data.names.map((_,i)=>i)); - } - const p=currentProjection();if(!p){$('projection-quality').textContent='Regenerate this report to include spatial projections.';return;} - projectionHits=p.coordinates.map(projectionPoint); - const original=new Set(data.neighbours[selected]),projected=new Set(p.neighbours[selected]); - ctx.lineWidth=1;ctx.strokeStyle='#dce3e9';ctx.fillStyle='#52687b';ctx.font='12px system-ui'; - // Both axes use the same units per pixel, including after pan/zoom. - for(let i=0;i<=4;i++){ - const x=60+i*270,y=30+i*135; - ctx.beginPath();ctx.moveTo(x,20);ctx.lineTo(x,570);ctx.stroke(); - ctx.fillText((projectionView.x+(x-600)/projectionView.scale).toPrecision(3),x+3,590); - ctx.beginPath();ctx.moveTo(35,y);ctx.lineTo(1180,y);ctx.stroke(); - ctx.fillText((projectionView.y-(y-300)/projectionView.scale).toPrecision(3),2,y+13); - } - ctx.fillText(p.axes[0]+' →',1120,15);ctx.fillText(p.axes[1],5,15); - for(let i=0;i1200||y<0||y>600)continue; - ctx.fillStyle='#7c90a34d';ctx.beginPath();ctx.arc(x,y,2,0,2*Math.PI);ctx.fill(); - } - const [sx,sy]=projectionHits[selected]; - if($('projection-edges').checked){ctx.strokeStyle='#087e8b88';for(const i of original){const [x,y]=projectionHits[i];ctx.beginPath();ctx.moveTo(sx,sy);ctx.lineTo(x,y);ctx.stroke();}} - for(const i of projected){if(original.has(i))continue;const [x,y]=projectionHits[i];ctx.strokeStyle='#c87719';ctx.lineWidth=1.8;ctx.beginPath();ctx.arc(x,y,5,0,2*Math.PI);ctx.stroke();} - ctx.font='12px system-ui'; - for(const i of original){const [x,y]=projectionHits[i];ctx.fillStyle='#087e8b';ctx.beginPath();ctx.arc(x,y,4.5,0,2*Math.PI);ctx.fill();if(!$('map-photos').checked)ctx.fillText(data.names[i],x+7,y-5);} - ctx.fillStyle='#c2344b';ctx.beginPath();ctx.arc(sx,sy,6,0,2*Math.PI);ctx.fill();ctx.font='bold 13px system-ui';if(!$('map-photos').checked)ctx.fillText(data.names[selected],sx+9,sy+17); - const fractions=p.variance_fraction?.map(v=>(v*100).toFixed(2)); - const summary=fractions?`Variance retained: ${(100*p.variance_fraction.reduce((a,b)=>a+b,0)).toFixed(2)}% (${p.axes[0]} ${fractions[0]}%, ${p.axes[1]} ${fractions[1]}%). `:`Angular t-SNE · perplexity ${p.parameters.perplexity.toFixed(1)} · seed ${p.parameters.seed}. Map gaps and areas do not measure spherical distances or areas. `; - $('projection-quality').textContent=`${summary}Original top-${data.stats.k} neighbours retained among plane top-${data.stats.k}: ${(100*p.mean_retained_fraction).toFixed(1)}% averaged over classes; ${(100*p.retained_fraction[selected]).toFixed(1)}% for selected class. Exact plane-distance ties use class order.`; - if(typeof scheduleMapThumbnails==='function')scheduleMapThumbnails(); -} -function projectionMouse(event){const r=projectionCanvas.getBoundingClientRect();return [(event.clientX-r.left)*1200/r.width,(event.clientY-r.top)*600/r.height];} -function projectionHit(point){if(typeof mapThumbnailHit==='function'){const hit=mapThumbnailHit(point);if(hit>=0)return hit;}let best=-1,distance=100;projectionHits.forEach(([x,y],i)=>{const d=(x-point[0])**2+(y-point[1])**2;if(d{if(e.button!==0)return;projectionDrag={point:projectionMouse(e),view:{...projectionView},moved:false};projectionCanvas.setPointerCapture(e.pointerId);}; -projectionCanvas.onpointermove=e=>{ - const point=projectionMouse(e); - if(projectionDrag){const dx=point[0]-projectionDrag.point[0],dy=point[1]-projectionDrag.point[1];if(Math.hypot(dx,dy)>3)projectionDrag.moved=true;projectionView.x=projectionDrag.view.x-dx/projectionView.scale;projectionView.y=projectionDrag.view.y+dy/projectionView.scale;drawProjection();return;} - const id=projectionHit(point); - if(typeof showMapPhotoCredit==='function')showMapPhotoCredit(id); - $('projection-hover').textContent=id<0?'Hover a point for its class ID.':`Class ${data.names[id]} · checkpoint row ${id}${id===selected?' · selected':data.neighbours[selected].includes(id)?' · original-space neighbour':currentProjection().neighbours[selected].includes(id)?' · nearby in this plane only':''}`; -}; -projectionCanvas.onpointerup=e=>{if(!projectionDrag)return;const click=!projectionDrag.moved;projectionDrag=null;if(projectionCanvas.hasPointerCapture(e.pointerId))projectionCanvas.releasePointerCapture(e.pointerId);if(click){const id=projectionHit(projectionMouse(e));if(id>=0)selectClass(id);}}; -projectionCanvas.onpointercancel=()=>{projectionDrag=null;}; -projectionCanvas.addEventListener('wheel',e=>{ - e.preventDefault();const [x,y]=projectionMouse(e),old=projectionView.scale; - const scale=Math.max(1,Math.min(1e7,old*Math.exp(-Math.max(-300,Math.min(300,e.deltaY))*.002))); - projectionView.x+=(x-600)*(1/old-1/scale);projectionView.y-=(y-300)*(1/old-1/scale);projectionView.scale=scale;drawProjection(); -},{passive:false}); -$('projection-plane').onchange=()=>{fitProjection(data.names.map((_,i)=>i));drawProjection();}; -$('projection-reset').onclick=()=>{fitProjection(data.names.map((_,i)=>i));drawProjection();}; -$('projection-focus').onclick=()=>{fitProjection([selected,...data.neighbours[selected]]);drawProjection();}; -$('projection-edges').onchange=drawProjection; -drawProjection(); diff --git a/dev/prototype_space/projection.js b/dev/prototype_space/projection.js new file mode 120000 index 00000000..684b3164 --- /dev/null +++ b/dev/prototype_space/projection.js @@ -0,0 +1 @@ +../../mini_trainer/visualization/prototype_space/projection.js \ No newline at end of file diff --git a/dev/prototype_space/projection.py b/dev/prototype_space/projection.py index 4ee0a4ab..aa9f8e4d 100644 --- a/dev/prototype_space/projection.py +++ b/dev/prototype_space/projection.py @@ -1,112 +1,3 @@ -"""Linear spatial views with measured neighbourhood preservation.""" +"""Compatibility entry point for the packaged prototype explorer.""" -import numpy as np -import torch -from scipy.spatial import cKDTree - - -def projected_neighbours(coordinates, k): - """Euclidean neighbours in the displayed plane; exact ties use class order.""" - coordinates = np.asarray(coordinates, dtype=np.float64) - tree = cKDTree(coordinates) - distances, _ = tree.query(coordinates, k=k + 1) - result = [] - for i, radius in enumerate(distances[:, -1]): - candidates = np.asarray(tree.query_ball_point(coordinates[i], np.nextafter(radius, np.inf)), dtype=int) - candidates = candidates[candidates != i] - squared = ((coordinates[candidates] - coordinates[i]) ** 2).sum(axis=1) - result.append(candidates[np.lexsort((candidates, squared))[:k]].tolist()) - return result - - -@torch.no_grad() -def prototype_projections(weight, neighbours): - """Project the same row directions used in the cosine diagnostic. - - PCA centers unit rows without whitening or feature standardization. It does - not change prototype weights, repository distances or their neighbour ranks. - Both displayed axes share one spatial scale. Fit each case independently. - """ - weight = weight.detach().cpu().float() - norms = weight.norm(dim=1, keepdim=True) - if not torch.isfinite(weight).all() or (norms == 0).any(): - raise ValueError("Projection requires finite, nonzero prototype rows") - directions = weight / norms - centered = directions - directions.mean(dim=0) - # Symmetric covariance eigendecomposition avoids an N x N pairwise matrix. - gram = centered.T @ centered - eigenvalues, eigenvectors = torch.linalg.eigh(gram) - total = centered.square().sum().item() - count = min(4, weight.shape[1]) - axes = eigenvectors[:, -count:].flip(1) - # Fix the otherwise arbitrary sign; degenerate eigenspaces may still rotate. - pivots = axes.abs().argmax(dim=0) - axes *= torch.where(axes[pivots, torch.arange(count)] < 0, -1.0, 1.0) - coordinates = (centered @ axes).numpy() - variances = eigenvalues[-count:].flip(0).clamp_min(0).numpy() - result = {} - original = [set(row) for row in neighbours] - k = len(neighbours[0]) - for start in range(0, count - 1, 2): - points = coordinates[:, start : start + 2] - near = projected_neighbours(points, k) - retained = [len(original[i].intersection(row)) / k for i, row in enumerate(near)] - key = f"PCA {start + 1}–{start + 2}" - result[key] = { - "coordinates": points.tolist(), - "axes": [f"PC {start + 1}", f"PC {start + 2}"], - "variance_fraction": (variances[start : start + 2] / total).tolist() if total > 0 else [0.0, 0.0], - "neighbours": near, - "retained_fraction": retained, - "mean_retained_fraction": float(np.mean(retained)), - "method": "Centered PCA of unit prototype directions; float32 covariance eigendecomposition; no whitening", - } - return result - - -def angular_distances(z, dimensions): - """Invert the repository's clamped cosine-to-z transform to radians.""" - angles = np.maximum(0, np.pi / 2 - np.asarray(z, dtype=np.float32) / np.sqrt(np.float32(dimensions - 2))) - np.fill_diagonal(angles, 0) - return angles - - -def angular_tsne(z, dimensions, neighbours, seed=42, *, iterations=1000): - """An explicit nonlinear display of angular neighbourhoods, not a new metric.""" - from sklearn.manifold import TSNE - - perplexity = min(30.0, (len(z) - 1) / 3) - estimator = TSNE( - metric="precomputed", - init="random", - perplexity=perplexity, - random_state=seed, - learning_rate="auto", - max_iter=iterations, - method="barnes_hut", - angle=0.5, - n_jobs=4, - ) - points = estimator.fit_transform(angular_distances(z, dimensions)) - near = projected_neighbours(points, len(neighbours[0])) - retained = [len(set(a).intersection(b)) / len(a) for a, b in zip(neighbours, near)] - return { - "coordinates": points.tolist(), - "axes": ["t-SNE 1", "t-SNE 2"], - "variance_fraction": None, - "neighbours": near, - "retained_fraction": retained, - "mean_retained_fraction": float(np.mean(retained)), - "method": ( - "t-SNE of angular distances recovered from repository z; map areas and inter-cluster gaps are not spherical areas or distances" - ), - "parameters": { - "perplexity": perplexity, - "seed": seed, - "iterations": iterations, - "init": "random", - "metric": "precomputed angular radians", - "angle": 0.5, - }, - "kl_divergence": float(estimator.kl_divergence_), - } +from mini_trainer.visualization.prototype_space.projection import * # noqa: F403 diff --git a/dev/prototype_space/report.html b/dev/prototype_space/report.html deleted file mode 100644 index d75e4f5f..00000000 --- a/dev/prototype_space/report.html +++ /dev/null @@ -1,137 +0,0 @@ - - - - -Prototype observatory - -
mini_trainer · empirical exploration

Prototype observatory

Move between the global tree, pairwise structure and a class’s local neighbourhood. The existing mini_trainer distance is the reference; the pre-CDF z-score exposes detail that can be hidden by probability rounding.

-
- - - -
-
-

-
-
-

Distribution of pairwise z-scores

The actual transform used by mini_trainer, before the Normal CDF. Self-pairs excluded. No fitted distribution or assumed organization.

-

Neighbourhood signature

Similarity to the 1st, 5th, 12th and 32nd neighbour in the full space. High values across ranks indicate several strongly aligned directions.

Teal: selected class. Grey: per-rank median across classes. Rank points are not an embedding projection.

-
-

Prototype space · spatial projection

-

Compare linear PCA with angular t-SNE when available. Scroll to zoom, drag to pan, click a point or thumbnail to inspect its class. Each case is fitted separately.

-
-
-

-

Hover a point for its class ID.

Red: selected class · teal: its neighbours in the original space · orange rings: nearby in this plane only · grey: other classes. Links follow the original-space ranking. Projected gaps and apparent overlaps can differ from the full embedding geometry.

-

Browse by class image

Photo labels for the selected class and its nearest prototype directions. The prototype marks the learned target direction for the class; a GBIF class example provides its visual label. Credits and exact class IDs stay attached.

-
-

Explore the existing dendrogram

-

Same Ward linkage on class_distance, shown as a rectangular tree with at most 32 visible tips. Click a collapsed tip to expand its subtree; click a class to inspect it. Heights retain the existing linkage units.

-

-
-
-

What survives matrix aggregation?

Both panels represent the current heatmap quantity, 1 − CDF(z), in the same dendrogram leaf order. The log-domain option evaluates log Φ(−z) directly in float32. Legacy mode exposes CDF saturation; the two panels compare block summaries. Self-pairs and padding are excluded from both.

-

Clipping affects colours only, not numerical values or neighbour rankings.

-

Maximum dissimilarity per block

Uses the current renderer’s maximum-summary idea. A distant pair can dominate a block.

Closest pair per block

An explicit alternative. Reveals isolated close pairs; does not imply the entire block is close.

-

-
-
-

Local observatory ·

-

-

Hover a cell for its exact stored values; click to select its column class.

Nearest directions

Ranked by repository z-score, descending. This explicitly refines ties in baseline distance; exactly tied z-scores retain checkpoint class order.

RankClass IDBaseline dlog₁₀ tailzSame parent
-
-

Questions this opens up

With prototype weights alone

Compare local crowding across taxonomy; follow cross-branch nearest neighbours; measure how many baseline distances tie; examine neighbour stability across checkpoints. Keep alternate metrics alongside the reference rather than silently replacing it.

With held-out image embeddings

Add sample-to-prototype alignment, decision margins, observed occupancy and actual confusion. Those quantities are not inferred from this weight-only report. A sampled decision-territory experiment would be a separate geometric reference.

Provenance and limits

Class and parent IDs come from checkpoint metadata; no online taxonomic remapping. A single checkpoint does not establish a training trajectory. Synthetic cases are deliberately constructed display/calculation probes, not targets. Their smaller class counts do not provide a matched packing comparison. Zero distance may result from CDF rounding and does not prove identical vectors. Grey matrix cells contain no off-diagonal pairs; display flooring never alters stored numerical values.

-
Self-contained numerical report · optional GBIF photo lookup when enabled · computations use mini_trainer’s class_similarity and class_distance APIs.
-
- - - - - - diff --git a/dev/prototype_space/report.html b/dev/prototype_space/report.html new file mode 120000 index 00000000..2bb448af --- /dev/null +++ b/dev/prototype_space/report.html @@ -0,0 +1 @@ +../../mini_trainer/visualization/prototype_space/report.html \ No newline at end of file diff --git a/dev/prototype_space/serve.py b/dev/prototype_space/serve.py index 88406363..e8f86382 100644 --- a/dev/prototype_space/serve.py +++ b/dev/prototype_space/serve.py @@ -1,199 +1,7 @@ -"""Serve the local prototype report with optional, cached GBIF reference photos.""" - -import argparse -import hashlib -import json -import re -import threading -from functools import partial -from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer -from pathlib import Path -from urllib.error import HTTPError -from urllib.parse import urlsplit -from urllib.request import urlopen - -from diskcache import Cache - -from mini_trainer.integrations import gbif - - -def web_url(value): - return isinstance(value, str) and urlsplit(value).scheme in {"http", "https"} - - -def reference_photos(class_id, taxon, occurrences, limit=6): - """Keep requested IDs, verify taxonomic membership, and retain media credits. - - These are GBIF examples, not images selected by model similarity. In - particular, an occurrence's data license is not assumed to license its photo. - """ - accepted = str(taxon.get("acceptedKey") or class_id) - photos, seen = [], set() - for occurrence in occurrences: - membership = { - str(occurrence.get(key)) - for key in ( - "taxonKey", - "acceptedTaxonKey", - "speciesKey", - "genusKey", - "familyKey", - "orderKey", - "classKey", - "phylumKey", - "kingdomKey", - ) - } - occurrence_id = str(occurrence.get("key", "")) - if not {str(class_id), accepted}.intersection(membership) or not occurrence_id.isdigit(): - continue - for media in occurrence.get("media", []): - identifier = media.get("identifier") - if media.get("type") != "StillImage" or not web_url(identifier) or identifier in seen: - continue - seen.add(identifier) - digest = hashlib.md5(identifier.encode(), usedforsecurity=False).hexdigest() - photos.append( - { - "occurrence_id": occurrence_id, - "image_path": f"/gbif-image/{occurrence_id}/{digest}", - "original": identifier, - "source": media.get("references") - if web_url(media.get("references")) - else f"https://www.gbif.org/occurrence/{occurrence_id}", - "creator": media.get("creator") or media.get("rightsHolder") or "Creator not supplied", - "license": media.get("license") or "Image license not supplied", - "scientific_name": occurrence.get("scientificName", ""), - } - ) - if len(photos) >= limit: - break - if len(photos) >= limit: - break - return { - "class_id": str(class_id), - "display_name": taxon.get("canonicalName") or taxon.get("scientificName") or str(class_id), - "accepted_id": accepted, - "taxonomic_status": taxon.get("taxonomicStatus"), - "photos": photos, - "selection": "GBIF class examples", - } - - -class PhotoService: - def __init__(self, allowed_ids, cache): - self.allowed_ids = set(allowed_ids) - self.cache = cache - self.image_paths = set() - self.metadata_cache = {} - # GBIF asks scripted image-cache users to use a single HTTP connection. - # Serialize all upstream requests, including image proxy requests. - self.lock = threading.Lock() - - def metadata(self, class_id): - if class_id not in self.allowed_ids or not class_id.isdigit(): - raise ValueError("Class is not a GBIF-ID candidate in this report") - if class_id in self.metadata_cache: - return self.metadata_cache[class_id] - with self.lock: - if class_id in self.metadata_cache: - return self.metadata_cache[class_id] - taxon = gbif.retrive_request(f"{gbif.GBIF_SPECIES_API_ENDPOINT}{class_id}") - result = gbif.retrive_request(f"https://api.gbif.org/v1/occurrence/search?taxonKey={class_id}&mediaType=StillImage&limit=20") - data = reference_photos(class_id, taxon, result.get("results", [])) - self.image_paths.update(photo["image_path"] for photo in data["photos"]) - self.metadata_cache[class_id] = data - return data - - def image(self, path): - if path not in self.image_paths: - raise ValueError("Image has not been resolved for a class in this report") - cache_key = "prototype-image:" + path - cached = self.cache.get(cache_key) - if cached is not None: - return cached - with self.lock: - cached = self.cache.get(cache_key) - if cached is not None: - return cached - occurrence, digest = path.split("/")[-2:] - url = f"https://api.gbif.org/v1/image/cache/400x/occurrence/{occurrence}/media/{digest}" - with urlopen(url, timeout=15) as response: - content_type = response.headers.get_content_type() - if content_type not in {"image/jpeg", "image/png", "image/webp", "image/gif"}: - raise ValueError("Upstream response was not a supported image") - content = response.read(8 * 1024 * 1024 + 1) - if len(content) > 8 * 1024 * 1024: - raise ValueError("Thumbnail exceeded the 8 MiB limit") - result = content, content_type - self.cache.set(cache_key, result, expire=gbif.CACHE_TIME) - return result - - -class Handler(SimpleHTTPRequestHandler): - def __init__(self, *args, service, **kwargs): - self.service = service - super().__init__(*args, **kwargs) - - def send_json(self, payload, status=200): - content = json.dumps(payload).encode() - self.send_response(status) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(content))) - self.end_headers() - self.wfile.write(content) - - def do_GET(self): - path = urlsplit(self.path).path - try: - if path == "/api/health": - return self.send_json({"photo_api": True}) - if path.startswith("/api/gbif/"): - return self.send_json(self.service.metadata(path.removeprefix("/api/gbif/"))) - if re.fullmatch(r"/gbif-image/\d+/[a-f0-9]{32}", path): - content, content_type = self.service.image(path) - self.send_response(200) - self.send_header("Content-Type", content_type) - self.send_header("Content-Length", str(len(content))) - self.send_header("Cache-Control", "private, max-age=86400") - self.end_headers() - self.wfile.write(content) - return None - return super().do_GET() - except (BrokenPipeError, ConnectionResetError): - return None # Navigation can cancel a queued photo request. - except (ValueError, HTTPError, OSError, RuntimeError) as error: - try: - return self.send_json({"error": str(error)}, status=502) - except (BrokenPipeError, ConnectionResetError): - return None - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--directory", type=Path, default=Path("tmp/prototype-report")) - parser.add_argument("--port", type=int, default=8765) - args = parser.parse_args() - directory = args.directory.resolve() - data = json.loads((directory / "report-data.json").read_text()) - ids = {name for case in data.values() if not case["metadata"].get("synthetic") for name in case["names"] if name.isdigit()} - del data - # Reuse the repository request cache without writing into the user's shared - # home cache. Keep it outside the directory exposed by the report server. - cache = Cache(str(directory.with_name(directory.name + "-gbif-cache")), size_limit=256 * 1024 * 1024) - gbif._CACHE = cache - service = PhotoService(ids, cache) - handler = partial(Handler, directory=str(directory), service=service) - server = ThreadingHTTPServer(("127.0.0.1", args.port), handler) - print(f"Prototype + optional GBIF photos: http://localhost:{args.port}/explorer.html", flush=True) - try: - server.serve_forever() - except KeyboardInterrupt: - pass - finally: - server.server_close() - cache.close() +"""Compatibility entry point for the packaged prototype explorer.""" +from mini_trainer.visualization.prototype_space.serve import * # noqa: F403 +from mini_trainer.visualization.prototype_space.serve import main if __name__ == "__main__": main() diff --git a/dev/prototype_space/thumbnails.js b/dev/prototype_space/thumbnails.js deleted file mode 100644 index d3e8b214..00000000 --- a/dev/prototype_space/thumbnails.js +++ /dev/null @@ -1,324 +0,0 @@ -/* Optional label relaxation changes thumbnail positions only, never prototype coordinates. */ -let mapThumbTimer = null, mapThumbController = null, mapThumbGeneration = 0; -let mapThumbLayout = []; -const mapThumbImages = new Map(); -const mapThumbFailures = new Set(); -function thumbnailSharedArea(a,b){ - return Math.max(0,Math.min(a.left+a.width,b.left+b.width)-Math.max(a.left,b.left))*Math.max(0,Math.min(a.top+a.height,b.top+b.height)-Math.max(a.top,b.top)); -} -function thumbnailEnergy(boxes,gradient=false){ - let energy=0,overlap=0,pairs=0; - // Diagonal contact-curvature estimate preconditions crowded and free axes - // differently; a floor bounds steps along weakly constrained directions. - const curvature=gradient?boxes.map(()=>[.05,.05]):null; - const forces=gradient?boxes.map(b=>[(b.anchorX-b.x)*.003,(b.anchorY-b.y)*.003]):null; - for(const b of boxes)energy+=.0015*((b.x-b.anchorX)**2+(b.y-b.anchorY)**2); - for(let i=0;i2&&py>2){pairs++;overlap=Math.max(overlap,(px-2)*(py-2)/Math.min(a.width*a.height,b.width*b.height));} - // Smooth minimum penetration of the actual border boxes. The forces below - // are the negative gradient of this same, time-independent contact energy. - const depth=Math.min(px,py),wx=Math.exp(-(px-depth)/2),wy=Math.exp(-(py-depth)/2); - const contact=Math.max(0,depth-2*Math.log(wx+wy));energy+=.5*contact*contact; - if(!gradient||contact===0)continue; - const angle=((a.id*137.508+b.id*71.3)%360)*Math.PI/180; - const sx=dx===0?(Math.cos(angle)<0?-1:1):Math.sign(dx),sy=dy===0?(Math.sin(angle)<0?-1:1):Math.sign(dy); - const fx=sx*contact*wx/(wx+wy),fy=sy*contact*wy/(wx+wy); - const hx=(wx/(wx+wy))**2,hy=(wy/(wx+wy))**2;curvature[i][0]+=hx;curvature[j][0]+=hx;curvature[i][1]+=hy;curvature[j][1]+=hy; - forces[i][0]-=fx;forces[i][1]-=fy;forces[j][0]+=fx;forces[j][1]+=fy; - } - return {energy,overlap,pairs,forces,curvature}; -} -function stepThumbnailForce(boxes,width,height,maxShift,state={}){ - if(typeof state!=='object')state={}; - const before=thumbnailEnergy(boxes,true),positions=boxes.map(b=>[b.x,b.y]); - let rate=state.rate||1,after=before,speed=0,accepted=false; - for(let trial=0;trial<14;trial++){ - speed=0; - boxes.forEach((b,i)=>{ - const momentum=trial===0?.55:0; - let dx=before.forces[i][0]*rate/before.curvature[i][0]+(b.vx||0)*momentum,dy=before.forces[i][1]*rate/before.curvature[i][1]+(b.vy||0)*momentum; - const distance=Math.hypot(dx,dy);if(distance>8){dx*=8/distance;dy*=8/distance;} - let x=Math.max(b.width/2,Math.min(width-b.width/2,positions[i][0]+dx)),y=Math.max(b.height/2,Math.min(height-b.height/2,positions[i][1]+dy)); - const offset=Math.hypot(x-b.anchorX,y-b.anchorY); - if(offset>maxShift){x=b.anchorX+(x-b.anchorX)*maxShift/offset;y=b.anchorY+(y-b.anchorY)*maxShift/offset;} - b.x=x;b.y=y;speed=Math.max(speed,Math.hypot(x-positions[i][0],y-positions[i][1])); - }); - after=thumbnailEnergy(boxes); - if(after.energy<=before.energy+1e-9){accepted=true;break;} - // Retry without inertia before shortening the gradient step. - if(trial>0)rate*=.5; - } - if(!accepted){boxes.forEach((b,i)=>{b.x=positions[i][0];b.y=positions[i][1];});after=before;speed=0;} - boxes.forEach((b,i)=>{b.vx=b.x-positions[i][0];b.vy=b.y-positions[i][1];b.left=b.x-b.width/2;b.top=b.y-b.height/2;}); - state.rate=Math.min(4,rate*1.1);state.steps=(state.steps||0)+1; - const small=speed<.04&&Math.abs(after.energy-before.energy)/Math.max(1,boxes.length)<.002&&Math.abs(after.overlap-before.overlap)<1e-4; - state.quiet=small?(state.quiet||0)+1:0; - state.metrics={energy:after.energy,overlap:after.overlap,pairs:after.pairs,speed}; - state.done=state.quiet>=8||state.steps>=480; - state.reason=state.quiet>=8?'resting':'work limit'; - return speed; -} -function relaxThumbnails(boxes,width,height,maxShift){ - const state={};while(!state.done)stepThumbnailForce(boxes,width,height,maxShift,state); - return boxes; -} -// Cascaded time-domain filters separate solver convergence from visible motion. -// A speed-limited guide and its continuous follower use CSS pixels and seconds. -// Convex combinations preserve viewport and anchor-radius bounds; retaining the -// guide on each box preserves velocity when loading or navigation reheats layout. -function stepThumbnailPresentation(boxes,targets,seconds){ - const omega=8,maxSpeed=140; - let remaining=Math.min(.05,Math.max(0,seconds)); - while(remaining>1e-9){ - const dt=Math.min(1/240,remaining);remaining-=dt; - boxes.forEach((b,i)=>{ - const target=targets[i];b.guideX??=b.x;b.guideY??=b.y; - const dx=target.x-b.guideX,dy=target.y-b.guideY,distance=Math.hypot(dx,dy); - const fraction=distance?Math.min(1-Math.exp(-omega*dt),maxSpeed*dt/distance):0; - const decay=Math.exp(-omega*dt); - for(const [key,guide] of [['x','guideX'],['y','guideY']]){ - const start=b[guide],velocity=(target[key]-start)*fraction/dt; - // Exact follower response to a linearly moving guide in this substep. - b[key]=start+velocity*dt-velocity/omega+(b[key]-start+velocity/omega)*decay; - b[guide]=start+velocity*dt; - } - }); - } - let speed=0,error=0; - boxes.forEach((b,i)=>{ - b.left=b.x-b.width/2;b.top=b.y-b.height/2; - speed=Math.max(speed,omega*Math.hypot((b.guideX??b.x)-b.x,(b.guideY??b.y)-b.y)); - error=Math.max(error,Math.hypot(targets[i].x-b.x,targets[i].y-b.y),Math.hypot(targets[i].x-(b.guideX??b.x),targets[i].y-(b.guideY??b.y))); - }); - return {speed,error,done:speed<.02&&error<.005}; -} -let mapThumbMotion=null; -function stopThumbnailMotion(){ - if(!mapThumbMotion)return; - cancelAnimationFrame(mapThumbMotion.frame); - mapThumbMotion.resolve();mapThumbMotion=null; -} -function paintThumbnailMotion(){ - for(const box of mapThumbLayout){ - box.button.style.left=box.x+'px';box.button.style.top=box.y+'px'; - if(!box.tether)continue; - const line=box.tether.querySelector('line'); - line.setAttribute('x1',box.anchorX);line.setAttribute('y1',box.anchorY); - const dot=box.tether.querySelector('circle');dot.setAttribute('cx',box.anchorX);dot.setAttribute('cy',box.anchorY); - line.setAttribute('x2',Math.max(box.left,Math.min(box.left+box.width,box.anchorX))); - line.setAttribute('y2',Math.max(box.top,Math.min(box.top+box.height,box.anchorY))); - box.tether.style.visibility=Math.hypot(box.x-box.anchorX,box.y-box.anchorY)<.5?'hidden':'visible'; - } -} -let mapThumbRest=null; -function animateThumbnails(config){ - // Image arrivals join the existing frame loop; they never reset its clock, - // numerical destinations, or presentation velocity. - if(mapThumbMotion)return mapThumbMotion.promise; - mapThumbRest=null; - const motion={frame:0,state:{},last:0,targets:[],byId:new Map()}; - mapThumbMotion=motion; - return motion.promise=new Promise(resolve=>{ - motion.resolve=resolve; - const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches; - const finish=()=>{ - const measured=thumbnailEnergy(mapThumbLayout); - mapThumbRest={...motion.state.metrics,energy:measured.energy,overlap:measured.overlap,pairs:measured.pairs,reason:motion.state.reason,steps:motion.state.steps,presentationSpeed:motion.presentation?.speed||0}; - paintThumbnailMotion();mapThumbMotion=null;resolve(); - }; - const syncTargets=time=>{ - const active=mapThumbLayout.filter(b=>reduced||!b.readyAt||time>=b.readyAt); - const changed=active.length!==motion.byId.size||active.some(b=>!motion.byId.has(b.id)); - motion.targets=active.map(b=>motion.byId.get(b.id)||({...b,vx:0,vy:0})); - motion.byId=new Map(motion.targets.map(b=>[b.id,b])); - if(changed)motion.state={}; - return active.length===mapThumbLayout.length; - }; - const advance=()=>stepThumbnailForce(motion.targets,config.width,config.height,2*config.size,motion.state); - if(reduced){ - syncTargets(Infinity); - while(!motion.state.done)advance(); - mapThumbLayout.forEach((b,i)=>{b.x=motion.targets[i].x;b.y=motion.targets[i].y;b.guideX=b.x;b.guideY=b.y;b.left=b.x-b.width/2;b.top=b.y-b.height/2;}); - finish();return; - } - const tick=time=>{ - if(mapThumbMotion!==motion)return; - const dt=motion.last?(time-motion.last)/1000:0;motion.last=time; - const allReady=syncTargets(time); - for(let i=0;i<2&&!motion.state.done;i++)advance(); - // Each new image waits independently; existing contacts and destinations - // continue evolving while it fades in. Match by ID, not admission order. - const targets=mapThumbLayout.map(b=>motion.byId.get(b.id)||b); - motion.presentation=stepThumbnailPresentation(mapThumbLayout,targets,dt); - paintThumbnailMotion(); - if(allReady&&motion.state.done&&motion.presentation.done){finish();return;} - motion.frame=requestAnimationFrame(tick); - }; - motion.frame=requestAnimationFrame(tick); - }); -} -function thumbnailFootprint(size,labels,aspect=1){ - const ratio=Number.isFinite(aspect)&&aspect>0?aspect:1; - const imageWidth=size*Math.min(1,ratio),imageHeight=size*Math.min(1,1/ratio); - return {imageWidth,imageHeight,width:imageWidth+(labels?8:0),height:imageHeight+(labels?24:0)}; -} -function thumbnailLayout(points, priority, {width,height,size,density,overlap,labels=true,push=false,aspects=[],unknownOverlap=overlap}) { - const budget=Math.min(200,Math.max(1,Math.floor(width*height*density/1e6))); - const chosen=[],used=new Set(); - for(const id of priority){ - if(used.has(id))continue;used.add(id);const [x,y]=points[id]; - const {width:boxWidth,height:boxHeight}=thumbnailFootprint(size,labels,aspects[id]); - const box={id,x,y,anchorX:x,anchorY:y,left:x-boxWidth/2,top:y-boxHeight/2,width:boxWidth,height:boxHeight}; - if(box.left<0||box.top<0||box.left+boxWidth>width||box.top+boxHeight>height)continue; - const area=boxWidth*boxHeight; - const blocked=chosen.some(other=>{const limit=aspects[id]>0&&aspects[other.id]>0?overlap:unknownOverlap;return thumbnailSharedArea(box,other)/Math.min(area,other.width*other.height)>limit+1e-12;}); - if(!blocked)chosen.push(box);if(chosen.length>=budget)break; - } - const boxes=push?relaxThumbnails(chosen,width,height,2*size):chosen; - return {boxes,budget,culled:chosen.length-boxes.length}; -} -function thumbnailTether(box,always=false){ - if(!always&&Math.hypot(box.x-box.anchorX,box.y-box.anchorY)<.5)return null; - const ns='http://www.w3.org/2000/svg',svg=document.createElementNS(ns,'svg'); - svg.classList.add('map-thumb-tether');svg.setAttribute('aria-hidden','true');svg.style.zIndex='201'; - const line=document.createElementNS(ns,'line'); - const edgeX=Math.max(box.left,Math.min(box.left+box.width,box.anchorX)),edgeY=Math.max(box.top,Math.min(box.top+box.height,box.anchorY)); - for(const [key,value] of Object.entries({x1:box.anchorX,y1:box.anchorY,x2:edgeX,y2:edgeY,stroke:'#435b70','stroke-width':1}))line.setAttribute(key,String(value)); - const dot=document.createElementNS(ns,'circle'); - for(const [key,value] of Object.entries({cx:box.anchorX,cy:box.anchorY,r:3,fill:'#ffffff',stroke:'#243b50','stroke-width':1.5}))dot.setAttribute(key,String(value)); - svg.append(line,dot);return svg; -} -function mapThumbnailHit(point){ - const rect=projectionCanvas.getBoundingClientRect(),x=point[0]*rect.width/1200,y=point[1]*rect.height/600; - const box=mapThumbLayout.find(b=>x>=b.left&&x<=b.left+b.width&&y>=b.top&&y<=b.top+b.height); - return box?.id??-1; -} -function showMapPhotoCredit(index){ - const node=$('map-photo-credit');node.replaceChildren();if(index<0)return; - const id=data.names[index],album=photoAlbums.get(id);if(!album?.photos.length)return; - const photo=album.photos[(photoChoices.get(id)||0)%album.photos.length]; - node.append(photoText('span',`${album.display_name} · class ${id} · ${photo.creator} · ${photo.license} · `),photoLink('Image source',photo.source),photoText('span',' · '),photoLink('GBIF record','https://www.gbif.org/occurrence/'+photo.occurrence_id)); -} -let mapThumbCase=null,mapThumbKey='',mapThumbOffsets=new Map(); -function retireThumbnail(box){ - // Remove interaction immediately; the short visual exit is independent of - // solver membership and cannot delay newly visible thumbnails. - box.button.disabled=true;box.button.style.pointerEvents='none'; - box.button.setAttribute('aria-hidden','true'); - for(const node of [box.button,box.tether]){ - if(!node)continue; - if(matchMedia('(prefers-reduced-motion: reduce)').matches){node.remove();continue;} - const opacity=getComputedStyle(node).opacity; - const fade=node.animate([{opacity},{opacity:0}],{duration:100,fill:'forwards'}); - fade.onfinish=()=>node.remove(); - } -} -function reanchorThumbnails(points,width,height){ - const kept=[]; - for(const box of mapThumbLayout){ - const point=points[box.id];if(!point){retireThumbnail(box);continue;} - const dx=box.x-box.anchorX,dy=box.y-box.anchorY; - if(box.guideX!==undefined){box.guideX+=point[0]-box.anchorX;box.guideY+=point[1]-box.anchorY;} - box.anchorX=point[0];box.anchorY=point[1];box.x=point[0]+dx;box.y=point[1]+dy; - box.left=box.x-box.width/2;box.top=box.y-box.height/2; - if(box.left<0||box.top<0||box.left+box.width>width||box.top+box.height>height){retireThumbnail(box);continue;} - kept.push(box); - } - mapThumbLayout=kept;paintThumbnailMotion(); -} -function setThumbnailAnchors(){ - $('map-thumbnail-layer').classList.toggle('hide-thumb-anchors',!$('map-photo-anchors').checked); -} -function scheduleMapThumbnails(){ - clearTimeout(mapThumbTimer);stopThumbnailMotion();mapThumbController?.abort();++mapThumbGeneration; - const size=Number($('map-photo-size').value),density=Number($('map-photo-density').value),overlap=Number($('map-photo-overlap').value); - const key=[size,$('map-photo-labels').checked,$('map-photo-push').checked,$('map-photo-aspect').checked,$('projection-plane').value].join(':'); - if($('map-photos').checked&&mapThumbCase===data&&mapThumbKey===key){ - const rect=projectionCanvas.getBoundingClientRect(); - reanchorThumbnails(projectionHits.map(([x,y])=>[x*rect.width/1200,y*rect.height/600]),rect.width,rect.height); - mapThumbOffsets=new Map(mapThumbLayout.map(b=>[b.id,[b.x-b.anchorX,b.y-b.anchorY]])); - }else{mapThumbOffsets.clear();mapThumbLayout=[];$('map-thumbnail-layer').replaceChildren();} - mapThumbCase=data;mapThumbKey=key; - $('map-size-value').value=size+' px';$('map-density-value').value=density+' / MP';$('map-overlap-value').value=overlap+'%'; - if(!$('map-photos').checked){$('map-photo-status').textContent='Enable map thumbnails to interpret numeric class IDs as GBIF taxa. Push apart connects displaced images to their fixed prototype points.';return;} - if(data.metadata.synthetic){$('map-photo-status').textContent='Synthetic case: no GBIF photo requests.';return;} - $('map-photo-status').textContent='Updating visible thumbnails…'; - mapThumbTimer=setTimeout(renderMapThumbnails,180); -} -async function renderMapThumbnails(){ - const generation=mapThumbGeneration,caseData=data; - mapThumbController=new AbortController();const signal=mapThumbController.signal; - const rect=projectionCanvas.getBoundingClientRect(),size=Number($('map-photo-size').value); - const config={width:rect.width,height:rect.height,size,density:Number($('map-photo-density').value),overlap:Number($('map-photo-overlap').value)/100,labels:$('map-photo-labels').checked,push:$('map-photo-push').checked,aspect:$('map-photo-aspect').checked}; - if(config.aspect)config.aspects=data.names.map(id=>{const album=photoAlbums.get(id),photo=album?.photos[(photoChoices.get(id)||0)%(album?.photos.length||1)],image=photo&&mapThumbImages.get(photo.image_path);return image?.naturalWidth/image?.naturalHeight;}); - const points=projectionHits.map(([x,y])=>[x*rect.width/1200,y*rect.height/600]); - // Selected class first, then its true neighbours; other classes have stable - // mixed row order so dense regions do not win simply by checkpoint ordering. - const remaining=data.names.map((_,i)=>i).sort((a,b)=>(Math.imul(a,2654435761)>>>0)-(Math.imul(b,2654435761)>>>0)); - const priority=[selected,...data.neighbours[selected],...remaining].filter(i=>photoNumber(data.names[i])&&!mapThumbFailures.has(data.names[i])); - const {boxes,budget}=thumbnailLayout(points,priority,{...config,push:false,unknownOverlap:config.aspect&&!config.push?0:config.overlap}); - const ranks=new Map(boxes.map((b,i)=>[b.id,i])); - const retained=new Map(); - for(const box of mapThumbLayout){ - if(ranks.has(box.id)&&box.photoChoice===(photoChoices.get(data.names[box.id])||0))retained.set(box.id,box); - else{retireThumbnail(box);} - } - mapThumbLayout=[...retained.values()].sort((a,b)=>ranks.get(a.id)-ranks.get(b.id)); - - let motionFinished=false; - let loaded=0,unavailable=0; - const status=()=>{$('map-photo-status').textContent=`${loaded} images loaded · ${motionFinished?mapThumbLayout.length:boxes.length} visible slots · budget ${budget} for this viewport. ${config.push?`Push apart: ${motionFinished?`${mapThumbRest?.reason||'resting'} · ${mapThumbRest?.pairs||0} overlapping pairs retained`:'adjusting rectangle contacts'}. Images move at most ${2*size} px.`:`Maximum pairwise rectangle overlap ${Math.round(config.overlap*100)}%.`} ${unavailable} unavailable. Hover for names and credits; click to inspect. Prototype coordinates and neighbour ranks stay fixed.`;}; - if(!boxes.length){status();return;} - try{if(boxes.some(b=>!retained.has(b.id))){const health=await fetch('/api/health',{signal});if(!health.ok||!(await health.json()).photo_api)throw Error('Photo server unavailable');}} - catch(error){if(!signal.aborted)$('map-photo-status').textContent='Map photos require the local GBIF photo server. Numerical views remain available.';return;} - const loadBox=async box=>{ - if(signal.aborted||generation!==mapThumbGeneration||data!==caseData)return; - const id=data.names[box.id]; - if(retained.has(box.id)){loaded++;const old=retained.get(box.id);old.button.style.zIndex=String(boxes.length-ranks.get(box.id));old.button.style.borderColor=box.id===selected?'#c2344b':data.neighbours[selected].includes(box.id)?'#087e8b':'#7c90a3';return;} - try{ - let album=photoAlbums.get(id); - if(!album){const response=await fetch('/api/gbif/'+id,{signal});album=await response.json();if(!response.ok)throw Error(album.error||'Photo lookup failed');photoAlbums.set(id,album);} - if(signal.aborted||generation!==mapThumbGeneration)return; - if(!album.photos.length){mapThumbFailures.add(id);unavailable++;status();return;} - const choice=(photoChoices.get(id)||0)%album.photos.length,photo=album.photos[choice]; - let image=mapThumbImages.get(photo.image_path)?.cloneNode(); - const button=photoText('button','','map-thumb');button.type='button';button.style.left=box.x+'px';button.style.top=box.y+'px';button.style.width=box.width+'px';button.style.height=box.height+'px';button.style.zIndex=String(boxes.length-boxes.indexOf(box));button.style.borderColor=box.id===selected?'#c2344b':data.neighbours[selected].includes(box.id)?'#087e8b':'#7c90a3';button.setAttribute('aria-label',`Inspect ${album.display_name}, class ${id}`);button.onclick=()=>selectClass(box.id);button.onfocus=()=>showMapPhotoCredit(box.id); - button.classList.toggle('image-only',!config.labels);button.classList.toggle('original-aspect',config.aspect);button.title=`${album.display_name} · class ${id}`; - if(!image){image=new Image();image.decoding='async';} - image.draggable=false;image.alt=album.display_name;image.style.height=size+'px'; - button.append(image);if(config.labels)button.append(photoText('span',id)); - const tether=thumbnailTether(box,config.push); - const okay=(image.complete&&image.naturalWidth>0)||await loadReferenceImage(image,photo.image_path,signal); - if(signal.aborted||generation!==mapThumbGeneration)return; - if(okay){ - const footprint=thumbnailFootprint(size,config.labels,config.aspect?image.naturalWidth/image.naturalHeight:1); - box.width=footprint.width;box.height=footprint.height;box.left=box.x-box.width/2;box.top=box.y-box.height/2; - button.style.width=box.width+'px';button.style.height=box.height+'px';image.style.height=footprint.imageHeight+'px'; - box.button=button;box.tether=tether;box.photoChoice=photoChoices.get(id)||0;box.readyAt=performance.now()+80; - const offset=config.push?mapThumbOffsets.get(box.id):null; - if(offset){box.x=Math.max(box.width/2,Math.min(config.width-box.width/2,box.anchorX+offset[0]));box.y=Math.max(box.height/2,Math.min(config.height-box.height/2,box.anchorY+offset[1]));box.left=box.x-box.width/2;box.top=box.y-box.height/2;button.style.left=box.x+'px';button.style.top=box.y+'px';} - if(tether)$('map-thumbnail-layer').append(tether);$('map-thumbnail-layer').append(button); - const bounds=button.getBoundingClientRect();box.width=bounds.width;box.height=bounds.height;box.left=box.x-box.width/2;box.top=box.y-box.height/2; - mapThumbLayout.push(box);mapThumbLayout.sort((a,b)=>ranks.get(a.id)-ranks.get(b.id)); - if(config.push)animateThumbnails(config); - loaded++;mapThumbImages.delete(photo.image_path);mapThumbImages.set(photo.image_path,image);while(mapThumbImages.size>128)mapThumbImages.delete(mapThumbImages.keys().next().value);} - else{button.remove();tether?.remove();mapThumbLayout=mapThumbLayout.filter(b=>b!==box);mapThumbFailures.add(id);unavailable++;} - status();if(box.id===selected)showMapPhotoCredit(selected); - }catch(error){if(signal.aborted)return;mapThumbFailures.add(id);unavailable++;status();} - }; - // Bound in-flight work; a slow image no longer stalls every later class. - let next=0; - await Promise.all(Array.from({length:Math.min(4,boxes.length)},async()=>{ - while(next{mapThumbFailures.clear();scheduleMapThumbnails();}; -$('map-photo-anchors').oninput=setThumbnailAnchors;setThumbnailAnchors(); -new ResizeObserver(scheduleMapThumbnails).observe(projectionCanvas); -scheduleMapThumbnails(); diff --git a/dev/prototype_space/thumbnails.js b/dev/prototype_space/thumbnails.js new file mode 120000 index 00000000..3ebc6bf5 --- /dev/null +++ b/dev/prototype_space/thumbnails.js @@ -0,0 +1 @@ +../../mini_trainer/visualization/prototype_space/thumbnails.js \ No newline at end of file diff --git a/dev/wheel_smoke.py b/dev/wheel_smoke.py index 43a319df..df55f475 100644 --- a/dev/wheel_smoke.py +++ b/dev/wheel_smoke.py @@ -52,6 +52,14 @@ def main(): if entry.group == "console_scripts": subprocess.run([str(Path(sys.executable).parent / entry.name), "--help"], check=True, timeout=60, capture_output=True) + assets = importlib.resources.files("mini_trainer.visualization.prototype_space") + for name in ("report.html", "launcher.html", "photos.js", "projection.js", "thumbnails.js"): + assert assets.joinpath(name).read_text(), f"Missing packaged explorer asset: {name}" + missing_explorer = subprocess.run( + [str(Path(sys.executable).parent / "mt_explore"), "--no-browser"], capture_output=True, text=True, timeout=30 + ) + assert missing_explorer.returncode != 0 and "mini_trainer[explorer]" in missing_explorer.stderr + root = Path.cwd() data = root / "images" for label, color in (("dark", (20, 30, 40)), ("light", (200, 210, 220))): diff --git a/docs/prototype-explorer.md b/docs/prototype-explorer.md new file mode 100644 index 00000000..20050910 --- /dev/null +++ b/docs/prototype-explorer.md @@ -0,0 +1,100 @@ +# Prototype explorer + +Explore a mini_trainer classifier's learned prototype geometry through linked +class-distance matrices, dendrograms, spatial projections, and optional GBIF +class photos. The viewer uses the repository's effective-weight handling and +class-distance functions. Projections and movable image labels do not change +prototype coordinates or neighbour rankings. + +Install the optional dependencies in your intended environment: + +```bash +uv pip install 'mini_trainer[explorer]' --torch-backend=auto +``` + +For an existing training environment, preserve its chosen PyTorch backend as +explained in the [installation guide](../README.md#installation). + +## Open a model + +```bash +mt_explore path/to/weights.pt +``` + +This opens a local browser page while the report is prepared. No output path, +class mapping, model architecture, or embedding dimension argument is required. +The information comes from the saved classifier metadata. The tool uses CPU +analysis and defaults to both angular t-SNE and PCA projections. + +To select a file using the browser instead: + +```bash +mt_explore +``` + +Choose **Model weights**, then **Open weights**. After preparation, click +**Open explorer**. The viewer's **Open another model** link returns to the picker. +The file is sent only to the local process, stored outside the served report +folder, and read with PyTorch's `weights_only=True` loader. The server binds to +loopback on an available port. Stop it with Ctrl+C when finished. Session files +are temporary; use **Save HTML report** to retain the portable numerical viewer. +Photos require the local server and network access to GBIF. + +For SSH or an environment without a browser, use `--no-browser` and open the +printed URL through your usual local port forwarding. `--port` optionally fixes +the listening port. File selection reads from the browser's computer. + +The equivalent module command works without an installed console entry point: + +```bash +python -m mini_trainer.visualization.prototype_space path/to/weights.pt +``` + +## Export without serving + +```bash +mt_explore path/to/weights.pt --export +``` + +The default output directory is `weights-explorer` in the current directory. +Use `--output /path/to/report` to choose another directory. Export writes +`explorer.html`, `report-data.json`, and `summary.json`; the HTML contains its +numerical data and scripts and can be opened offline. + +For programmatic generation: + +```python +from mini_trainer.visualization.prototype_space.launch import generate + +generate('weights.pt', 'report') +``` + +Optional controls: + +- `--pca-only`: skip angular t-SNE for a faster initial inspection. +- `--synthetic`: include independent vectors, planted groups, and algebraic + edge cases as explicitly labelled comparison cases. +- `--threads 4`: bound CPU analysis threads (default: 4). + +No dependencies are installed automatically and the source checkpoint is not +modified. Full pairwise analysis uses quadratic memory; a 12,632-class report +needs several GB of RAM and can take minutes. Synthetic cases probe the +calculations and rendering; they are not a prior for the learned geometry. + +## Supported weights and interpretation + +The loader currently supports floating-point **float32 linear heads** saved by +mini_trainer's `Classifier` and `HierarchicalClassifier`, including normalized +weight parametrizations and the checkpoint's class ordering. It reads the head +without constructing or downloading a backbone. Bias and norm ranges and the +checkpoint SHA-256 remain in report provenance. + +Other head families, quantized encodings, and files without class metadata are +rejected explicitly. They require a defined prototype-extraction contract; +the explorer does not guess tensor keys or substitute raw parametrizations. +The supplied file should be the model weights file, not optimizer/training state. + +See the [view guide](../dev/prototype_space/README.md#try-the-views), +[diagnostic API](prototype-diagnostics.md), and +[hyperspherical visualization review](hyperspherical-visualization.md) for +metric semantics, display clipping, projection distortion and thumbnail controls. diff --git a/mini_trainer/visualization/prototype_space/__init__.py b/mini_trainer/visualization/prototype_space/__init__.py new file mode 100644 index 00000000..32b8604e --- /dev/null +++ b/mini_trainer/visualization/prototype_space/__init__.py @@ -0,0 +1 @@ +"""Interactive exploration of classifier prototype geometry.""" diff --git a/mini_trainer/visualization/prototype_space/__main__.py b/mini_trainer/visualization/prototype_space/__main__.py new file mode 100644 index 00000000..a58a8ef0 --- /dev/null +++ b/mini_trainer/visualization/prototype_space/__main__.py @@ -0,0 +1,5 @@ +"""Run with python -m mini_trainer.visualization.prototype_space.""" + +from .launch import run + +run() diff --git a/mini_trainer/visualization/prototype_space/explore.py b/mini_trainer/visualization/prototype_space/explore.py new file mode 100644 index 00000000..d6f0f6bf --- /dev/null +++ b/mini_trainer/visualization/prototype_space/explore.py @@ -0,0 +1,283 @@ +"""Offline prototype exploration using mini_trainer's existing diagnostic APIs.""" + +import argparse +import base64 +import hashlib +import json +import math +from pathlib import Path + +import numpy as np +import torch +from matplotlib import colormaps +from scipy.cluster.hierarchy import linkage +from scipy.spatial.distance import squareform +from torch import nn + +from mini_trainer.modeling import Classifier, class_distance, class_log_similarity, class_similarity +from mini_trainer.visualization._dendrogram_layout import linkage_layout + +from .projection import angular_tsne, prototype_projections + + +def weight_model(weight: torch.Tensor) -> nn.Module: + """A linear-only diagnostic head: values are already effective weights.""" + with torch.device("meta"): + head = Classifier(weight.shape[1], weight.shape[0], hidden=False, normalized=False) + head.linear = nn.Linear(weight.shape[1], weight.shape[0], bias=False, device="meta") + head.linear.load_state_dict({"weight": weight}, assign=True) + return nn.Sequential(head) + + +def load_prototypes(path: Path): + """Read supported floating-point linear heads without building a backbone. + + This is deliberately not a general checkpoint loader. Reject other head + families instead of treating a sublayer as the complete prototype space. + """ + state = torch.load(path, map_location="cpu", weights_only=True) + metas = [(key, value) for key, value in state.items() if key.endswith("._extra_state") and isinstance(value, dict)] + if len(metas) != 1: + raise ValueError("Expected exactly one classifier metadata record") + key, meta = metas[0] + if meta.get("classifier_class") not in { + "mini_trainer.modeling.classifier:Classifier", + "mini_trainer.hierarchical.model:HierarchicalClassifier", + }: + raise ValueError("Explorer supports only Classifier and HierarchicalClassifier linear prototypes") + prefix = key.removesuffix("_extra_state") + "linear." + linear = {key[len(prefix) :]: value for key, value in state.items() if key.startswith(prefix)} + normalized = meta.get("normalized") + weight_key = "parametrizations.weight.original1" if normalized else "weight" + raw = linear.get(weight_key) + if not isinstance(raw, torch.Tensor) or raw.ndim != 2 or raw.dtype != torch.float32: + raise ValueError("Expected float32 linear weights; quantized/other checkpoint encodings are not supported") + layer = nn.Linear(raw.shape[1], raw.shape[0], bias="bias" in linear) + if normalized: + layer = Classifier._normalize_layer(layer, orthogonal_init=False) + layer.load_state_dict(linear, strict=True) + weight = layer.weight.detach().clone() + if not torch.isfinite(weight).all() or not (weight.norm(dim=1) > 0).all(): + raise ValueError("Prototype rows must be finite and nonzero") + mapping = meta["cls2idx"] + if all(isinstance(value, dict) for value in mapping.values()): + mapping = mapping.get("0", mapping.get(0)) + if not mapping or sorted(mapping.values()) != list(range(len(weight))): + raise ValueError("Class mapping must cover every prototype exactly once") + names = [""] * len(weight) + for name, index in mapping.items(): + names[index] = str(name) + hierarchy = meta.get("labels", {}) + groups = [list(map(str, hierarchy.get(name, [name]))) for name in names] + with path.open("rb") as stream: + digest = hashlib.file_digest(stream, "sha256").hexdigest() + return ( + weight, + names, + groups, + { + "checkpoint_sha256": digest, + "checkpoint": path.name, + "classifier": meta["classifier_class"], + "normalized": normalized, + "norm_range": [weight.norm(dim=1).min().item(), weight.norm(dim=1).max().item()], + "bias_range": [layer.bias.min().item(), layer.bias.max().item()] if layer.bias is not None else None, + "analysis_script_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + "torch_version": str(torch.__version__), + "numpy_version": np.__version__, + }, + ) + + +def array_payload(array, dtype="=2 classes, >2 dimensions and >=1 neighbour") + model = weight_model(weight) + print(f"Computing repository z-scores and distances for {n:,} × {dim:,}", flush=True) + z = class_similarity(model, cdf=False)[0].numpy() + distance = class_distance(model)[0].numpy() + tail = 1 - class_similarity(model, cdf=True)[0].numpy() + log_tail = class_log_similarity(model, complement=True)[0].numpy() + assert np.isfinite(distance).all() and np.isfinite(z).all() + k = min(neighbours, n - 1) + # z is a separately labelled refinement of saturated distance ties. + # Stable sorting uses checkpoint class order for exactly equal z values. + near = np.empty((n, k), dtype=np.int32) + for i in range(n): + row = z[i].copy() + row[i] = -np.inf + near[i] = np.argsort(-row, kind="stable")[:k] + ids = np.column_stack((np.arange(n), near)) + local_d = distance[ids[:, :, None], ids[:, None, :]] + local_z = z[ids[:, :, None], ids[:, None, :]] + stable_near, _ = stable_scores(z[np.arange(n)[:, None], near]) + local_logtail = log_tail[ids[:, :, None], ids[:, None, :]] / math.log(10) + zero_count = (distance == 0).sum(axis=1) - 1 + recovered = 0 + for row in range(n): + zero_ids = np.flatnonzero(distance[row] == 0) + zero_ids = zero_ids[zero_ids > row] + stable_zero, _ = stable_scores(z[row, zero_ids]) + recovered += int((stable_zero > 0).sum()) + upper = np.triu_indices(n, 1) if n < 1024 else None + rng = np.random.default_rng(seed) + if upper is None: + i, j = rng.integers(n, size=(2, 200_000)) + i, j = i[i != j], j[i != j] + else: + i, j = upper + sampled = z[i, j] + robust_range = np.quantile(log_tail[i, j] / math.log(10), [0.001, 0.999]).tolist() + bins = np.linspace(-6, max(8, float(sampled.max()) + 0.1), 100) + hist, edges = np.histogram(sampled, bins) + stats = { + "classes": n, + "dimensions": dim, + "off_diagonal_zero_distance_pairs": int(zero_count.sum() // 2), + "classes_with_zero_distance_neighbours": int((zero_count > 0).sum()), + "zero_pairs_resolved_by_float64_logcdf": recovered, + "finite_off_diagonal_log_tail_pairs": int(np.isfinite(log_tail).sum() // 2), + "pair_sample_count": len(sampled), + "seed": seed, + "pair_sampling": "all unordered pairs" if upper is not None else "uniform ordered pairs with replacement, diagonal excluded", + "pair_z_mean": float(sampled.mean()), + "pair_z_std": float(sampled.std()), + "pair_z_quantiles": dict(zip(["1%", "50%", "99%", "max_sampled"], np.quantile(sampled, [0.01, 0.5, 0.99, 1]).tolist())), + "nearest_z_median": float(np.median(z[np.arange(n), near[:, 0]])), + "kth_z_median": float(np.median(z[np.arange(n), near[:, -1]])), + "k": k, + "histogram_excluded": int(len(sampled) - hist.sum()), + } + print(json.dumps(stats), flush=True) + print("Building unchanged Ward linkage on class_distance", flush=True) + tree = linkage(squareform(distance, checks=False), method="ward") + order, _, _, _ = linkage_layout(tree, np.ones(n, dtype=int)) + order = np.asarray(order) + low, high, block = block_extrema(tail, order) + log_low, log_high, _ = block_extrema(log_tail, order) + log_floor = math.floor(float(np.nanmin(log_low)) / math.log(10)) * math.log(10) + del distance, tail + ranks = [1, min(5, n - 1), min(12, n - 1), min(32, n - 1)] + profiles = np.empty((n, len(ranks)), dtype=np.float32) + for i in range(n): + row = z[i].copy() + row[i] = -np.inf + profiles[i] = -np.partition(-row, np.asarray(ranks) - 1)[np.asarray(ranks) - 1] + projections = prototype_projections(weight, near.tolist()) + if include_tsne: + print("Fitting angular t-SNE", flush=True) + projections["Angular t-SNE"] = angular_tsne(z, dim, near.tolist(), seed) + return { + "projections": projections, + "metadata": metadata or {}, + "stats": stats, + "names": names, + "groups": groups, + "neighbours": near.tolist(), + "local_distance": array_payload(local_d), + "local_z": array_payload(local_z), + "local_log10_tail": array_payload(local_logtail), + "stable_neighbour_distance": array_payload(stable_near.numpy(), dtype=" + +Prototype explorer — open weights + +

Explore model prototypes

Choose a mini_trainer weight file. The explorer reads the classifier weights and class labels, then prepares the distance matrix, dendrogram and spatial projections.

+

Analysis runs locally and can take several minutes for large models. Optional GBIF images are requested only when you enable them in the viewer.

+
+

Choose a checkpoint to begin.

+ diff --git a/mini_trainer/visualization/prototype_space/photos.js b/mini_trainer/visualization/prototype_space/photos.js new file mode 100644 index 00000000..4b484c46 --- /dev/null +++ b/mini_trainer/visualization/prototype_space/photos.js @@ -0,0 +1,140 @@ +/* Optional GBIF reference-photo layer. Numerical report works without this server. */ +let photoController = null; +let photoGeneration = 0; +const photoAlbums = new Map(); +const photoChoices = new Map(); +const photoNumber = id => /^\d+$/.test(id); + +function photoText(tag, text, className = '') { + const node = document.createElement(tag); + node.textContent = text; + node.className = className; + return node; +} + +function photoLink(text, href) { + const node = photoText('a', text); + node.href = href; + node.target = '_blank'; + node.rel = 'noopener noreferrer'; + return node; +} + +function loadReferenceImage(image, src, signal) { + return new Promise(resolve => { + let timer; + const done = ok => { + clearTimeout(timer); + image.onload = image.onerror = null; + signal.removeEventListener('abort', abort); + resolve(ok); + }; + const abort = () => { image.removeAttribute('src'); done(false); }; + if (signal.aborted) { done(false); return; } + signal.addEventListener('abort', abort, {once: true}); + image.onload = () => done(true); + image.onerror = () => done(false); + timer = setTimeout(abort, 18000); + // Keep the image visible during loading so progressive decoding can appear. + image.src = src; + }); +} + +async function showReference(card, album, classIndex, generation, signal) { + const photos = album.photos; + card.title.textContent = album.display_name; + if (!photos.length) { card.status.textContent = 'No matching class examples in the first 20 GBIF records.'; return; } + const choice = (photoChoices.get(album.class_id) || 0) % photos.length; + const photo = photos[choice]; + card.credit.replaceChildren( + photoText('span', photo.creator + ' · ' + photo.license + ' '), + photoLink('Source', photo.source), + photoText('span', ' · '), + photoLink('GBIF record', 'https://www.gbif.org/occurrence/' + photo.occurrence_id) + ); + card.image.alt = `GBIF example of ${album.display_name}; class ${album.class_id}`; + card.status.textContent = `Loading example ${choice + 1} / ${photos.length}…`; + card.next.hidden = photos.length < 2; + card.next.disabled = true; + card.next.onclick = async () => { + if (generation !== photoGeneration) return; + photoChoices.set(album.class_id, choice + 1); + card.next.disabled = true; + await showReference(card, album, classIndex, generation, signal); + if(typeof scheduleMapThumbnails==='function')scheduleMapThumbnails(); + card.next.disabled = false; + }; + const loaded = await loadReferenceImage(card.image, photo.image_path, signal); + if (generation !== photoGeneration) return; + card.next.disabled = false; + card.status.textContent = loaded ? `Example ${choice + 1} / ${photos.length}${album.accepted_id !== album.class_id ? ' · GBIF accepted ID ' + album.accepted_id : ''}` : 'Image unavailable or timed out. Try another example.'; +} + +async function renderPhotos() { + const generation = ++photoGeneration; + photoController?.abort(); + photoController = new AbortController(); + const signal = photoController.signal; + const grid = $('photo-grid'); + grid.replaceChildren(); + if (!$('photo-enabled').checked) { + $('photo-status').textContent = 'Enable to browse the selected class and its nearest directions using GBIF class examples.'; + return; + } + const ids = [selected, ...data.neighbours[selected]]; + if (data.metadata.synthetic || !ids.some(i => photoNumber(data.names[i]))) { + $('photo-status').textContent = 'This case has no GBIF-ID candidates. No online lookup was made.'; + return; + } + $('photo-status').textContent = 'Loading class examples sequentially; class IDs and numerical neighbourhoods stay unchanged.'; + try { + const health = await fetch('/api/health', {signal}); + if (!health.ok || !(await health.json()).photo_api) throw Error('Photo service unavailable'); + } catch (error) { + if (signal.aborted) return; + $('photo-status').textContent = 'Photo view needs the local photo server: .venv/bin/python -m dev.prototype_space.serve --directory tmp/prototype-report. Open its localhost URL.'; + return; + } + const cards = ids.map((classIndex, rank) => { + const card = photoText('article', '', 'photo-card' + (rank === 0 ? ' photo-selected' : '')); + const open = photoText('button', '', 'photo-open'); + open.type = 'button'; + open.setAttribute('aria-label', `Explore class ${data.names[classIndex]}`); + const image = document.createElement('img'); + image.decoding = 'async'; + open.append(image); + open.onclick = () => selectClass(classIndex); + const title = photoText('strong', data.names[classIndex], 'photo-title'); + const identity = photoText('span', `${rank === 0 ? 'Selected' : 'Neighbour ' + rank} · class ${data.names[classIndex]}`, 'caption'); + const status = photoText('p', 'Waiting…', 'caption'); + const next = photoText('button', 'Another example'); + next.type = 'button'; next.hidden = true; + const credit = photoText('p', '', 'photo-credit'); + card.append(open, title, identity, status, next, credit); + grid.append(card); + return {image, title, status, next, credit}; + }); + // Only fetch the visible neighbourhood. Cache metadata in-session and images + // on the local server; do not download one image for every checkpoint class. + for (let rank = 0; rank < ids.length; rank++) { + if (signal.aborted || generation !== photoGeneration) return; + const classIndex = ids[rank], id = data.names[classIndex], card = cards[rank]; + if (!photoNumber(id)) {card.status.textContent = 'Not a GBIF ID.'; continue;} + try { + let album = photoAlbums.get(id); + if (!album) { + const response = await fetch('/api/gbif/' + id, {signal}); + album = await response.json(); + if (!response.ok) throw Error(album.error || 'GBIF lookup failed'); + photoAlbums.set(id, album); + } + if (signal.aborted || generation !== photoGeneration) return; + await showReference(card, album, classIndex, generation, signal); + } catch (error) { + if (!signal.aborted) card.status.textContent = 'Example lookup unavailable. Toggle photos to retry.'; + } + } + if (generation === photoGeneration) $('photo-status').textContent = 'Click a photo to explore that class’s neighbours. Images label classes at their learned prototype directions.'; +} +$('photo-enabled').onchange = renderPhotos; +renderPhotos(); diff --git a/mini_trainer/visualization/prototype_space/projection.js b/mini_trainer/visualization/prototype_space/projection.js new file mode 100644 index 00000000..d4b1f383 --- /dev/null +++ b/mini_trainer/visualization/prototype_space/projection.js @@ -0,0 +1,73 @@ +/* Coordinates are fitted offline. This canvas changes only the viewing transform. */ +let projectionCase = null; +let projectionView = {x:0, y:0, scale:1}; +let projectionDrag = null; +let projectionHits = []; +const projectionCanvas = $('projection-map'); +function currentProjection() { return data.projections?.[$('projection-plane').value]; } +function projectionPoint(point) {return [600+(point[0]-projectionView.x)*projectionView.scale,300-(point[1]-projectionView.y)*projectionView.scale];} +function fitProjection(ids) { + const p=currentProjection();if(!p)return; + const points=ids.map(i=>p.coordinates[i]); + let x0=Infinity,x1=-Infinity,y0=Infinity,y1=-Infinity; + for(const [x,y] of points){x0=Math.min(x0,x);x1=Math.max(x1,x);y0=Math.min(y0,y);y1=Math.max(y1,y);} + projectionView={x:(x0+x1)/2,y:(y0+y1)/2,scale:Math.min(1050/Math.max(.0001,x1-x0),450/Math.max(.0001,y1-y0))}; +} +function drawProjection() { + const ctx=projectionCanvas.getContext('2d');ctx.clearRect(0,0,1200,600); + if(projectionCase!==data){ + projectionCase=data;$('projection-plane').replaceChildren(); + for(const name of Object.keys(data.projections||{})){const option=document.createElement('option');option.value=option.textContent=name;$('projection-plane').append(option);} + if(data.projections?.['Angular t-SNE'])$('projection-plane').value='Angular t-SNE'; + fitProjection(data.names.map((_,i)=>i)); + } + const p=currentProjection();if(!p){$('projection-quality').textContent='Regenerate this report to include spatial projections.';return;} + projectionHits=p.coordinates.map(projectionPoint); + const original=new Set(data.neighbours[selected]),projected=new Set(p.neighbours[selected]); + ctx.lineWidth=1;ctx.strokeStyle='#dce3e9';ctx.fillStyle='#52687b';ctx.font='12px system-ui'; + // Both axes use the same units per pixel, including after pan/zoom. + for(let i=0;i<=4;i++){ + const x=60+i*270,y=30+i*135; + ctx.beginPath();ctx.moveTo(x,20);ctx.lineTo(x,570);ctx.stroke(); + ctx.fillText((projectionView.x+(x-600)/projectionView.scale).toPrecision(3),x+3,590); + ctx.beginPath();ctx.moveTo(35,y);ctx.lineTo(1180,y);ctx.stroke(); + ctx.fillText((projectionView.y-(y-300)/projectionView.scale).toPrecision(3),2,y+13); + } + ctx.fillText(p.axes[0]+' →',1120,15);ctx.fillText(p.axes[1],5,15); + for(let i=0;i1200||y<0||y>600)continue; + ctx.fillStyle='#7c90a34d';ctx.beginPath();ctx.arc(x,y,2,0,2*Math.PI);ctx.fill(); + } + const [sx,sy]=projectionHits[selected]; + if($('projection-edges').checked){ctx.strokeStyle='#087e8b88';for(const i of original){const [x,y]=projectionHits[i];ctx.beginPath();ctx.moveTo(sx,sy);ctx.lineTo(x,y);ctx.stroke();}} + for(const i of projected){if(original.has(i))continue;const [x,y]=projectionHits[i];ctx.strokeStyle='#c87719';ctx.lineWidth=1.8;ctx.beginPath();ctx.arc(x,y,5,0,2*Math.PI);ctx.stroke();} + ctx.font='12px system-ui'; + for(const i of original){const [x,y]=projectionHits[i];ctx.fillStyle='#087e8b';ctx.beginPath();ctx.arc(x,y,4.5,0,2*Math.PI);ctx.fill();if(!$('map-photos').checked)ctx.fillText(data.names[i],x+7,y-5);} + ctx.fillStyle='#c2344b';ctx.beginPath();ctx.arc(sx,sy,6,0,2*Math.PI);ctx.fill();ctx.font='bold 13px system-ui';if(!$('map-photos').checked)ctx.fillText(data.names[selected],sx+9,sy+17); + const fractions=p.variance_fraction?.map(v=>(v*100).toFixed(2)); + const summary=fractions?`Variance retained: ${(100*p.variance_fraction.reduce((a,b)=>a+b,0)).toFixed(2)}% (${p.axes[0]} ${fractions[0]}%, ${p.axes[1]} ${fractions[1]}%). `:`Angular t-SNE · perplexity ${p.parameters.perplexity.toFixed(1)} · seed ${p.parameters.seed}. Map gaps and areas do not measure spherical distances or areas. `; + $('projection-quality').textContent=`${summary}Original top-${data.stats.k} neighbours retained among plane top-${data.stats.k}: ${(100*p.mean_retained_fraction).toFixed(1)}% averaged over classes; ${(100*p.retained_fraction[selected]).toFixed(1)}% for selected class. Exact plane-distance ties use class order.`; + if(typeof scheduleMapThumbnails==='function')scheduleMapThumbnails(); +} +function projectionMouse(event){const r=projectionCanvas.getBoundingClientRect();return [(event.clientX-r.left)*1200/r.width,(event.clientY-r.top)*600/r.height];} +function projectionHit(point){if(typeof mapThumbnailHit==='function'){const hit=mapThumbnailHit(point);if(hit>=0)return hit;}let best=-1,distance=100;projectionHits.forEach(([x,y],i)=>{const d=(x-point[0])**2+(y-point[1])**2;if(d{if(e.button!==0)return;projectionDrag={point:projectionMouse(e),view:{...projectionView},moved:false};projectionCanvas.setPointerCapture(e.pointerId);}; +projectionCanvas.onpointermove=e=>{ + const point=projectionMouse(e); + if(projectionDrag){const dx=point[0]-projectionDrag.point[0],dy=point[1]-projectionDrag.point[1];if(Math.hypot(dx,dy)>3)projectionDrag.moved=true;projectionView.x=projectionDrag.view.x-dx/projectionView.scale;projectionView.y=projectionDrag.view.y+dy/projectionView.scale;drawProjection();return;} + const id=projectionHit(point); + if(typeof showMapPhotoCredit==='function')showMapPhotoCredit(id); + $('projection-hover').textContent=id<0?'Hover a point for its class ID.':`Class ${data.names[id]} · checkpoint row ${id}${id===selected?' · selected':data.neighbours[selected].includes(id)?' · original-space neighbour':currentProjection().neighbours[selected].includes(id)?' · nearby in this plane only':''}`; +}; +projectionCanvas.onpointerup=e=>{if(!projectionDrag)return;const click=!projectionDrag.moved;projectionDrag=null;if(projectionCanvas.hasPointerCapture(e.pointerId))projectionCanvas.releasePointerCapture(e.pointerId);if(click){const id=projectionHit(projectionMouse(e));if(id>=0)selectClass(id);}}; +projectionCanvas.onpointercancel=()=>{projectionDrag=null;}; +projectionCanvas.addEventListener('wheel',e=>{ + e.preventDefault();const [x,y]=projectionMouse(e),old=projectionView.scale; + const scale=Math.max(1,Math.min(1e7,old*Math.exp(-Math.max(-300,Math.min(300,e.deltaY))*.002))); + projectionView.x+=(x-600)*(1/old-1/scale);projectionView.y-=(y-300)*(1/old-1/scale);projectionView.scale=scale;drawProjection(); +},{passive:false}); +$('projection-plane').onchange=()=>{fitProjection(data.names.map((_,i)=>i));drawProjection();}; +$('projection-reset').onclick=()=>{fitProjection(data.names.map((_,i)=>i));drawProjection();}; +$('projection-focus').onclick=()=>{fitProjection([selected,...data.neighbours[selected]]);drawProjection();}; +$('projection-edges').onchange=drawProjection; +drawProjection(); diff --git a/mini_trainer/visualization/prototype_space/projection.py b/mini_trainer/visualization/prototype_space/projection.py new file mode 100644 index 00000000..4ee0a4ab --- /dev/null +++ b/mini_trainer/visualization/prototype_space/projection.py @@ -0,0 +1,112 @@ +"""Linear spatial views with measured neighbourhood preservation.""" + +import numpy as np +import torch +from scipy.spatial import cKDTree + + +def projected_neighbours(coordinates, k): + """Euclidean neighbours in the displayed plane; exact ties use class order.""" + coordinates = np.asarray(coordinates, dtype=np.float64) + tree = cKDTree(coordinates) + distances, _ = tree.query(coordinates, k=k + 1) + result = [] + for i, radius in enumerate(distances[:, -1]): + candidates = np.asarray(tree.query_ball_point(coordinates[i], np.nextafter(radius, np.inf)), dtype=int) + candidates = candidates[candidates != i] + squared = ((coordinates[candidates] - coordinates[i]) ** 2).sum(axis=1) + result.append(candidates[np.lexsort((candidates, squared))[:k]].tolist()) + return result + + +@torch.no_grad() +def prototype_projections(weight, neighbours): + """Project the same row directions used in the cosine diagnostic. + + PCA centers unit rows without whitening or feature standardization. It does + not change prototype weights, repository distances or their neighbour ranks. + Both displayed axes share one spatial scale. Fit each case independently. + """ + weight = weight.detach().cpu().float() + norms = weight.norm(dim=1, keepdim=True) + if not torch.isfinite(weight).all() or (norms == 0).any(): + raise ValueError("Projection requires finite, nonzero prototype rows") + directions = weight / norms + centered = directions - directions.mean(dim=0) + # Symmetric covariance eigendecomposition avoids an N x N pairwise matrix. + gram = centered.T @ centered + eigenvalues, eigenvectors = torch.linalg.eigh(gram) + total = centered.square().sum().item() + count = min(4, weight.shape[1]) + axes = eigenvectors[:, -count:].flip(1) + # Fix the otherwise arbitrary sign; degenerate eigenspaces may still rotate. + pivots = axes.abs().argmax(dim=0) + axes *= torch.where(axes[pivots, torch.arange(count)] < 0, -1.0, 1.0) + coordinates = (centered @ axes).numpy() + variances = eigenvalues[-count:].flip(0).clamp_min(0).numpy() + result = {} + original = [set(row) for row in neighbours] + k = len(neighbours[0]) + for start in range(0, count - 1, 2): + points = coordinates[:, start : start + 2] + near = projected_neighbours(points, k) + retained = [len(original[i].intersection(row)) / k for i, row in enumerate(near)] + key = f"PCA {start + 1}–{start + 2}" + result[key] = { + "coordinates": points.tolist(), + "axes": [f"PC {start + 1}", f"PC {start + 2}"], + "variance_fraction": (variances[start : start + 2] / total).tolist() if total > 0 else [0.0, 0.0], + "neighbours": near, + "retained_fraction": retained, + "mean_retained_fraction": float(np.mean(retained)), + "method": "Centered PCA of unit prototype directions; float32 covariance eigendecomposition; no whitening", + } + return result + + +def angular_distances(z, dimensions): + """Invert the repository's clamped cosine-to-z transform to radians.""" + angles = np.maximum(0, np.pi / 2 - np.asarray(z, dtype=np.float32) / np.sqrt(np.float32(dimensions - 2))) + np.fill_diagonal(angles, 0) + return angles + + +def angular_tsne(z, dimensions, neighbours, seed=42, *, iterations=1000): + """An explicit nonlinear display of angular neighbourhoods, not a new metric.""" + from sklearn.manifold import TSNE + + perplexity = min(30.0, (len(z) - 1) / 3) + estimator = TSNE( + metric="precomputed", + init="random", + perplexity=perplexity, + random_state=seed, + learning_rate="auto", + max_iter=iterations, + method="barnes_hut", + angle=0.5, + n_jobs=4, + ) + points = estimator.fit_transform(angular_distances(z, dimensions)) + near = projected_neighbours(points, len(neighbours[0])) + retained = [len(set(a).intersection(b)) / len(a) for a, b in zip(neighbours, near)] + return { + "coordinates": points.tolist(), + "axes": ["t-SNE 1", "t-SNE 2"], + "variance_fraction": None, + "neighbours": near, + "retained_fraction": retained, + "mean_retained_fraction": float(np.mean(retained)), + "method": ( + "t-SNE of angular distances recovered from repository z; map areas and inter-cluster gaps are not spherical areas or distances" + ), + "parameters": { + "perplexity": perplexity, + "seed": seed, + "iterations": iterations, + "init": "random", + "metric": "precomputed angular radians", + "angle": 0.5, + }, + "kl_divergence": float(estimator.kl_divergence_), + } diff --git a/mini_trainer/visualization/prototype_space/report.html b/mini_trainer/visualization/prototype_space/report.html new file mode 100644 index 00000000..2a1b9c55 --- /dev/null +++ b/mini_trainer/visualization/prototype_space/report.html @@ -0,0 +1,143 @@ + + + + +Prototype observatory + +
mini_trainer · empirical exploration

Prototype observatory

Move between the global tree, pairwise structure and a class’s local neighbourhood. The existing mini_trainer distance is the reference; the pre-CDF z-score exposes detail that can be hidden by probability rounding.

+
+ +Save HTML report + + + +
+
+

+
+
+

Distribution of pairwise z-scores

The actual transform used by mini_trainer, before the Normal CDF. Self-pairs excluded. No fitted distribution or assumed organization.

+

Neighbourhood signature

Similarity to the 1st, 5th, 12th and 32nd neighbour in the full space. High values across ranks indicate several strongly aligned directions.

Teal: selected class. Grey: per-rank median across classes. Rank points are not an embedding projection.

+
+

Prototype space · spatial projection

+

Compare linear PCA with angular t-SNE when available. Scroll to zoom, drag to pan, click a point or thumbnail to inspect its class. Each case is fitted separately.

+
+
+

+

Hover a point for its class ID.

Red: selected class · teal: its neighbours in the original space · orange rings: nearby in this plane only · grey: other classes. Links follow the original-space ranking. Projected gaps and apparent overlaps can differ from the full embedding geometry.

+

Browse by class image

Photo labels for the selected class and its nearest prototype directions. The prototype marks the learned target direction for the class; a GBIF class example provides its visual label. Credits and exact class IDs stay attached.

+
+

Explore the existing dendrogram

+

Same Ward linkage on class_distance, shown as a rectangular tree with at most 32 visible tips. Click a collapsed tip to expand its subtree; click a class to inspect it. Heights retain the existing linkage units.

+

+
+
+

What survives matrix aggregation?

Both panels represent the current heatmap quantity, 1 − CDF(z), in the same dendrogram leaf order. The log-domain option evaluates log Φ(−z) directly in float32. Legacy mode exposes CDF saturation; the two panels compare block summaries. Self-pairs and padding are excluded from both.

+

Clipping affects colours only, not numerical values or neighbour rankings.

+

Maximum dissimilarity per block

Uses the current renderer’s maximum-summary idea. A distant pair can dominate a block.

Closest pair per block

An explicit alternative. Reveals isolated close pairs; does not imply the entire block is close.

+

+
+
+

Local observatory ·

+

+

Hover a cell for its exact stored values; click to select its column class.

Nearest directions

Ranked by repository z-score, descending. This explicitly refines ties in baseline distance; exactly tied z-scores retain checkpoint class order.

RankClass IDBaseline dlog₁₀ tailzSame parent
+
+

Questions this opens up

With prototype weights alone

Compare local crowding across taxonomy; follow cross-branch nearest neighbours; measure how many baseline distances tie; examine neighbour stability across checkpoints. Keep alternate metrics alongside the reference rather than silently replacing it.

With held-out image embeddings

Add sample-to-prototype alignment, decision margins, observed occupancy and actual confusion. Those quantities are not inferred from this weight-only report. A sampled decision-territory experiment would be a separate geometric reference.

Provenance and limits

Class and parent IDs come from checkpoint metadata; no online taxonomic remapping. A single checkpoint does not establish a training trajectory. Synthetic cases are deliberately constructed display/calculation probes, not targets. Their smaller class counts do not provide a matched packing comparison. Zero distance may result from CDF rounding and does not prove identical vectors. Grey matrix cells contain no off-diagonal pairs; display flooring never alters stored numerical values.

+
Self-contained numerical report · optional GBIF photo lookup when enabled · computations use mini_trainer’s class_similarity and class_distance APIs.
+
+ + + + + + + + diff --git a/mini_trainer/visualization/prototype_space/serve.py b/mini_trainer/visualization/prototype_space/serve.py new file mode 100644 index 00000000..88406363 --- /dev/null +++ b/mini_trainer/visualization/prototype_space/serve.py @@ -0,0 +1,199 @@ +"""Serve the local prototype report with optional, cached GBIF reference photos.""" + +import argparse +import hashlib +import json +import re +import threading +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.error import HTTPError +from urllib.parse import urlsplit +from urllib.request import urlopen + +from diskcache import Cache + +from mini_trainer.integrations import gbif + + +def web_url(value): + return isinstance(value, str) and urlsplit(value).scheme in {"http", "https"} + + +def reference_photos(class_id, taxon, occurrences, limit=6): + """Keep requested IDs, verify taxonomic membership, and retain media credits. + + These are GBIF examples, not images selected by model similarity. In + particular, an occurrence's data license is not assumed to license its photo. + """ + accepted = str(taxon.get("acceptedKey") or class_id) + photos, seen = [], set() + for occurrence in occurrences: + membership = { + str(occurrence.get(key)) + for key in ( + "taxonKey", + "acceptedTaxonKey", + "speciesKey", + "genusKey", + "familyKey", + "orderKey", + "classKey", + "phylumKey", + "kingdomKey", + ) + } + occurrence_id = str(occurrence.get("key", "")) + if not {str(class_id), accepted}.intersection(membership) or not occurrence_id.isdigit(): + continue + for media in occurrence.get("media", []): + identifier = media.get("identifier") + if media.get("type") != "StillImage" or not web_url(identifier) or identifier in seen: + continue + seen.add(identifier) + digest = hashlib.md5(identifier.encode(), usedforsecurity=False).hexdigest() + photos.append( + { + "occurrence_id": occurrence_id, + "image_path": f"/gbif-image/{occurrence_id}/{digest}", + "original": identifier, + "source": media.get("references") + if web_url(media.get("references")) + else f"https://www.gbif.org/occurrence/{occurrence_id}", + "creator": media.get("creator") or media.get("rightsHolder") or "Creator not supplied", + "license": media.get("license") or "Image license not supplied", + "scientific_name": occurrence.get("scientificName", ""), + } + ) + if len(photos) >= limit: + break + if len(photos) >= limit: + break + return { + "class_id": str(class_id), + "display_name": taxon.get("canonicalName") or taxon.get("scientificName") or str(class_id), + "accepted_id": accepted, + "taxonomic_status": taxon.get("taxonomicStatus"), + "photos": photos, + "selection": "GBIF class examples", + } + + +class PhotoService: + def __init__(self, allowed_ids, cache): + self.allowed_ids = set(allowed_ids) + self.cache = cache + self.image_paths = set() + self.metadata_cache = {} + # GBIF asks scripted image-cache users to use a single HTTP connection. + # Serialize all upstream requests, including image proxy requests. + self.lock = threading.Lock() + + def metadata(self, class_id): + if class_id not in self.allowed_ids or not class_id.isdigit(): + raise ValueError("Class is not a GBIF-ID candidate in this report") + if class_id in self.metadata_cache: + return self.metadata_cache[class_id] + with self.lock: + if class_id in self.metadata_cache: + return self.metadata_cache[class_id] + taxon = gbif.retrive_request(f"{gbif.GBIF_SPECIES_API_ENDPOINT}{class_id}") + result = gbif.retrive_request(f"https://api.gbif.org/v1/occurrence/search?taxonKey={class_id}&mediaType=StillImage&limit=20") + data = reference_photos(class_id, taxon, result.get("results", [])) + self.image_paths.update(photo["image_path"] for photo in data["photos"]) + self.metadata_cache[class_id] = data + return data + + def image(self, path): + if path not in self.image_paths: + raise ValueError("Image has not been resolved for a class in this report") + cache_key = "prototype-image:" + path + cached = self.cache.get(cache_key) + if cached is not None: + return cached + with self.lock: + cached = self.cache.get(cache_key) + if cached is not None: + return cached + occurrence, digest = path.split("/")[-2:] + url = f"https://api.gbif.org/v1/image/cache/400x/occurrence/{occurrence}/media/{digest}" + with urlopen(url, timeout=15) as response: + content_type = response.headers.get_content_type() + if content_type not in {"image/jpeg", "image/png", "image/webp", "image/gif"}: + raise ValueError("Upstream response was not a supported image") + content = response.read(8 * 1024 * 1024 + 1) + if len(content) > 8 * 1024 * 1024: + raise ValueError("Thumbnail exceeded the 8 MiB limit") + result = content, content_type + self.cache.set(cache_key, result, expire=gbif.CACHE_TIME) + return result + + +class Handler(SimpleHTTPRequestHandler): + def __init__(self, *args, service, **kwargs): + self.service = service + super().__init__(*args, **kwargs) + + def send_json(self, payload, status=200): + content = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(content))) + self.end_headers() + self.wfile.write(content) + + def do_GET(self): + path = urlsplit(self.path).path + try: + if path == "/api/health": + return self.send_json({"photo_api": True}) + if path.startswith("/api/gbif/"): + return self.send_json(self.service.metadata(path.removeprefix("/api/gbif/"))) + if re.fullmatch(r"/gbif-image/\d+/[a-f0-9]{32}", path): + content, content_type = self.service.image(path) + self.send_response(200) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(content))) + self.send_header("Cache-Control", "private, max-age=86400") + self.end_headers() + self.wfile.write(content) + return None + return super().do_GET() + except (BrokenPipeError, ConnectionResetError): + return None # Navigation can cancel a queued photo request. + except (ValueError, HTTPError, OSError, RuntimeError) as error: + try: + return self.send_json({"error": str(error)}, status=502) + except (BrokenPipeError, ConnectionResetError): + return None + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--directory", type=Path, default=Path("tmp/prototype-report")) + parser.add_argument("--port", type=int, default=8765) + args = parser.parse_args() + directory = args.directory.resolve() + data = json.loads((directory / "report-data.json").read_text()) + ids = {name for case in data.values() if not case["metadata"].get("synthetic") for name in case["names"] if name.isdigit()} + del data + # Reuse the repository request cache without writing into the user's shared + # home cache. Keep it outside the directory exposed by the report server. + cache = Cache(str(directory.with_name(directory.name + "-gbif-cache")), size_limit=256 * 1024 * 1024) + gbif._CACHE = cache + service = PhotoService(ids, cache) + handler = partial(Handler, directory=str(directory), service=service) + server = ThreadingHTTPServer(("127.0.0.1", args.port), handler) + print(f"Prototype + optional GBIF photos: http://localhost:{args.port}/explorer.html", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + cache.close() + + +if __name__ == "__main__": + main() diff --git a/mini_trainer/visualization/prototype_space/thumbnails.js b/mini_trainer/visualization/prototype_space/thumbnails.js new file mode 100644 index 00000000..d3e8b214 --- /dev/null +++ b/mini_trainer/visualization/prototype_space/thumbnails.js @@ -0,0 +1,324 @@ +/* Optional label relaxation changes thumbnail positions only, never prototype coordinates. */ +let mapThumbTimer = null, mapThumbController = null, mapThumbGeneration = 0; +let mapThumbLayout = []; +const mapThumbImages = new Map(); +const mapThumbFailures = new Set(); +function thumbnailSharedArea(a,b){ + return Math.max(0,Math.min(a.left+a.width,b.left+b.width)-Math.max(a.left,b.left))*Math.max(0,Math.min(a.top+a.height,b.top+b.height)-Math.max(a.top,b.top)); +} +function thumbnailEnergy(boxes,gradient=false){ + let energy=0,overlap=0,pairs=0; + // Diagonal contact-curvature estimate preconditions crowded and free axes + // differently; a floor bounds steps along weakly constrained directions. + const curvature=gradient?boxes.map(()=>[.05,.05]):null; + const forces=gradient?boxes.map(b=>[(b.anchorX-b.x)*.003,(b.anchorY-b.y)*.003]):null; + for(const b of boxes)energy+=.0015*((b.x-b.anchorX)**2+(b.y-b.anchorY)**2); + for(let i=0;i2&&py>2){pairs++;overlap=Math.max(overlap,(px-2)*(py-2)/Math.min(a.width*a.height,b.width*b.height));} + // Smooth minimum penetration of the actual border boxes. The forces below + // are the negative gradient of this same, time-independent contact energy. + const depth=Math.min(px,py),wx=Math.exp(-(px-depth)/2),wy=Math.exp(-(py-depth)/2); + const contact=Math.max(0,depth-2*Math.log(wx+wy));energy+=.5*contact*contact; + if(!gradient||contact===0)continue; + const angle=((a.id*137.508+b.id*71.3)%360)*Math.PI/180; + const sx=dx===0?(Math.cos(angle)<0?-1:1):Math.sign(dx),sy=dy===0?(Math.sin(angle)<0?-1:1):Math.sign(dy); + const fx=sx*contact*wx/(wx+wy),fy=sy*contact*wy/(wx+wy); + const hx=(wx/(wx+wy))**2,hy=(wy/(wx+wy))**2;curvature[i][0]+=hx;curvature[j][0]+=hx;curvature[i][1]+=hy;curvature[j][1]+=hy; + forces[i][0]-=fx;forces[i][1]-=fy;forces[j][0]+=fx;forces[j][1]+=fy; + } + return {energy,overlap,pairs,forces,curvature}; +} +function stepThumbnailForce(boxes,width,height,maxShift,state={}){ + if(typeof state!=='object')state={}; + const before=thumbnailEnergy(boxes,true),positions=boxes.map(b=>[b.x,b.y]); + let rate=state.rate||1,after=before,speed=0,accepted=false; + for(let trial=0;trial<14;trial++){ + speed=0; + boxes.forEach((b,i)=>{ + const momentum=trial===0?.55:0; + let dx=before.forces[i][0]*rate/before.curvature[i][0]+(b.vx||0)*momentum,dy=before.forces[i][1]*rate/before.curvature[i][1]+(b.vy||0)*momentum; + const distance=Math.hypot(dx,dy);if(distance>8){dx*=8/distance;dy*=8/distance;} + let x=Math.max(b.width/2,Math.min(width-b.width/2,positions[i][0]+dx)),y=Math.max(b.height/2,Math.min(height-b.height/2,positions[i][1]+dy)); + const offset=Math.hypot(x-b.anchorX,y-b.anchorY); + if(offset>maxShift){x=b.anchorX+(x-b.anchorX)*maxShift/offset;y=b.anchorY+(y-b.anchorY)*maxShift/offset;} + b.x=x;b.y=y;speed=Math.max(speed,Math.hypot(x-positions[i][0],y-positions[i][1])); + }); + after=thumbnailEnergy(boxes); + if(after.energy<=before.energy+1e-9){accepted=true;break;} + // Retry without inertia before shortening the gradient step. + if(trial>0)rate*=.5; + } + if(!accepted){boxes.forEach((b,i)=>{b.x=positions[i][0];b.y=positions[i][1];});after=before;speed=0;} + boxes.forEach((b,i)=>{b.vx=b.x-positions[i][0];b.vy=b.y-positions[i][1];b.left=b.x-b.width/2;b.top=b.y-b.height/2;}); + state.rate=Math.min(4,rate*1.1);state.steps=(state.steps||0)+1; + const small=speed<.04&&Math.abs(after.energy-before.energy)/Math.max(1,boxes.length)<.002&&Math.abs(after.overlap-before.overlap)<1e-4; + state.quiet=small?(state.quiet||0)+1:0; + state.metrics={energy:after.energy,overlap:after.overlap,pairs:after.pairs,speed}; + state.done=state.quiet>=8||state.steps>=480; + state.reason=state.quiet>=8?'resting':'work limit'; + return speed; +} +function relaxThumbnails(boxes,width,height,maxShift){ + const state={};while(!state.done)stepThumbnailForce(boxes,width,height,maxShift,state); + return boxes; +} +// Cascaded time-domain filters separate solver convergence from visible motion. +// A speed-limited guide and its continuous follower use CSS pixels and seconds. +// Convex combinations preserve viewport and anchor-radius bounds; retaining the +// guide on each box preserves velocity when loading or navigation reheats layout. +function stepThumbnailPresentation(boxes,targets,seconds){ + const omega=8,maxSpeed=140; + let remaining=Math.min(.05,Math.max(0,seconds)); + while(remaining>1e-9){ + const dt=Math.min(1/240,remaining);remaining-=dt; + boxes.forEach((b,i)=>{ + const target=targets[i];b.guideX??=b.x;b.guideY??=b.y; + const dx=target.x-b.guideX,dy=target.y-b.guideY,distance=Math.hypot(dx,dy); + const fraction=distance?Math.min(1-Math.exp(-omega*dt),maxSpeed*dt/distance):0; + const decay=Math.exp(-omega*dt); + for(const [key,guide] of [['x','guideX'],['y','guideY']]){ + const start=b[guide],velocity=(target[key]-start)*fraction/dt; + // Exact follower response to a linearly moving guide in this substep. + b[key]=start+velocity*dt-velocity/omega+(b[key]-start+velocity/omega)*decay; + b[guide]=start+velocity*dt; + } + }); + } + let speed=0,error=0; + boxes.forEach((b,i)=>{ + b.left=b.x-b.width/2;b.top=b.y-b.height/2; + speed=Math.max(speed,omega*Math.hypot((b.guideX??b.x)-b.x,(b.guideY??b.y)-b.y)); + error=Math.max(error,Math.hypot(targets[i].x-b.x,targets[i].y-b.y),Math.hypot(targets[i].x-(b.guideX??b.x),targets[i].y-(b.guideY??b.y))); + }); + return {speed,error,done:speed<.02&&error<.005}; +} +let mapThumbMotion=null; +function stopThumbnailMotion(){ + if(!mapThumbMotion)return; + cancelAnimationFrame(mapThumbMotion.frame); + mapThumbMotion.resolve();mapThumbMotion=null; +} +function paintThumbnailMotion(){ + for(const box of mapThumbLayout){ + box.button.style.left=box.x+'px';box.button.style.top=box.y+'px'; + if(!box.tether)continue; + const line=box.tether.querySelector('line'); + line.setAttribute('x1',box.anchorX);line.setAttribute('y1',box.anchorY); + const dot=box.tether.querySelector('circle');dot.setAttribute('cx',box.anchorX);dot.setAttribute('cy',box.anchorY); + line.setAttribute('x2',Math.max(box.left,Math.min(box.left+box.width,box.anchorX))); + line.setAttribute('y2',Math.max(box.top,Math.min(box.top+box.height,box.anchorY))); + box.tether.style.visibility=Math.hypot(box.x-box.anchorX,box.y-box.anchorY)<.5?'hidden':'visible'; + } +} +let mapThumbRest=null; +function animateThumbnails(config){ + // Image arrivals join the existing frame loop; they never reset its clock, + // numerical destinations, or presentation velocity. + if(mapThumbMotion)return mapThumbMotion.promise; + mapThumbRest=null; + const motion={frame:0,state:{},last:0,targets:[],byId:new Map()}; + mapThumbMotion=motion; + return motion.promise=new Promise(resolve=>{ + motion.resolve=resolve; + const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches; + const finish=()=>{ + const measured=thumbnailEnergy(mapThumbLayout); + mapThumbRest={...motion.state.metrics,energy:measured.energy,overlap:measured.overlap,pairs:measured.pairs,reason:motion.state.reason,steps:motion.state.steps,presentationSpeed:motion.presentation?.speed||0}; + paintThumbnailMotion();mapThumbMotion=null;resolve(); + }; + const syncTargets=time=>{ + const active=mapThumbLayout.filter(b=>reduced||!b.readyAt||time>=b.readyAt); + const changed=active.length!==motion.byId.size||active.some(b=>!motion.byId.has(b.id)); + motion.targets=active.map(b=>motion.byId.get(b.id)||({...b,vx:0,vy:0})); + motion.byId=new Map(motion.targets.map(b=>[b.id,b])); + if(changed)motion.state={}; + return active.length===mapThumbLayout.length; + }; + const advance=()=>stepThumbnailForce(motion.targets,config.width,config.height,2*config.size,motion.state); + if(reduced){ + syncTargets(Infinity); + while(!motion.state.done)advance(); + mapThumbLayout.forEach((b,i)=>{b.x=motion.targets[i].x;b.y=motion.targets[i].y;b.guideX=b.x;b.guideY=b.y;b.left=b.x-b.width/2;b.top=b.y-b.height/2;}); + finish();return; + } + const tick=time=>{ + if(mapThumbMotion!==motion)return; + const dt=motion.last?(time-motion.last)/1000:0;motion.last=time; + const allReady=syncTargets(time); + for(let i=0;i<2&&!motion.state.done;i++)advance(); + // Each new image waits independently; existing contacts and destinations + // continue evolving while it fades in. Match by ID, not admission order. + const targets=mapThumbLayout.map(b=>motion.byId.get(b.id)||b); + motion.presentation=stepThumbnailPresentation(mapThumbLayout,targets,dt); + paintThumbnailMotion(); + if(allReady&&motion.state.done&&motion.presentation.done){finish();return;} + motion.frame=requestAnimationFrame(tick); + }; + motion.frame=requestAnimationFrame(tick); + }); +} +function thumbnailFootprint(size,labels,aspect=1){ + const ratio=Number.isFinite(aspect)&&aspect>0?aspect:1; + const imageWidth=size*Math.min(1,ratio),imageHeight=size*Math.min(1,1/ratio); + return {imageWidth,imageHeight,width:imageWidth+(labels?8:0),height:imageHeight+(labels?24:0)}; +} +function thumbnailLayout(points, priority, {width,height,size,density,overlap,labels=true,push=false,aspects=[],unknownOverlap=overlap}) { + const budget=Math.min(200,Math.max(1,Math.floor(width*height*density/1e6))); + const chosen=[],used=new Set(); + for(const id of priority){ + if(used.has(id))continue;used.add(id);const [x,y]=points[id]; + const {width:boxWidth,height:boxHeight}=thumbnailFootprint(size,labels,aspects[id]); + const box={id,x,y,anchorX:x,anchorY:y,left:x-boxWidth/2,top:y-boxHeight/2,width:boxWidth,height:boxHeight}; + if(box.left<0||box.top<0||box.left+boxWidth>width||box.top+boxHeight>height)continue; + const area=boxWidth*boxHeight; + const blocked=chosen.some(other=>{const limit=aspects[id]>0&&aspects[other.id]>0?overlap:unknownOverlap;return thumbnailSharedArea(box,other)/Math.min(area,other.width*other.height)>limit+1e-12;}); + if(!blocked)chosen.push(box);if(chosen.length>=budget)break; + } + const boxes=push?relaxThumbnails(chosen,width,height,2*size):chosen; + return {boxes,budget,culled:chosen.length-boxes.length}; +} +function thumbnailTether(box,always=false){ + if(!always&&Math.hypot(box.x-box.anchorX,box.y-box.anchorY)<.5)return null; + const ns='http://www.w3.org/2000/svg',svg=document.createElementNS(ns,'svg'); + svg.classList.add('map-thumb-tether');svg.setAttribute('aria-hidden','true');svg.style.zIndex='201'; + const line=document.createElementNS(ns,'line'); + const edgeX=Math.max(box.left,Math.min(box.left+box.width,box.anchorX)),edgeY=Math.max(box.top,Math.min(box.top+box.height,box.anchorY)); + for(const [key,value] of Object.entries({x1:box.anchorX,y1:box.anchorY,x2:edgeX,y2:edgeY,stroke:'#435b70','stroke-width':1}))line.setAttribute(key,String(value)); + const dot=document.createElementNS(ns,'circle'); + for(const [key,value] of Object.entries({cx:box.anchorX,cy:box.anchorY,r:3,fill:'#ffffff',stroke:'#243b50','stroke-width':1.5}))dot.setAttribute(key,String(value)); + svg.append(line,dot);return svg; +} +function mapThumbnailHit(point){ + const rect=projectionCanvas.getBoundingClientRect(),x=point[0]*rect.width/1200,y=point[1]*rect.height/600; + const box=mapThumbLayout.find(b=>x>=b.left&&x<=b.left+b.width&&y>=b.top&&y<=b.top+b.height); + return box?.id??-1; +} +function showMapPhotoCredit(index){ + const node=$('map-photo-credit');node.replaceChildren();if(index<0)return; + const id=data.names[index],album=photoAlbums.get(id);if(!album?.photos.length)return; + const photo=album.photos[(photoChoices.get(id)||0)%album.photos.length]; + node.append(photoText('span',`${album.display_name} · class ${id} · ${photo.creator} · ${photo.license} · `),photoLink('Image source',photo.source),photoText('span',' · '),photoLink('GBIF record','https://www.gbif.org/occurrence/'+photo.occurrence_id)); +} +let mapThumbCase=null,mapThumbKey='',mapThumbOffsets=new Map(); +function retireThumbnail(box){ + // Remove interaction immediately; the short visual exit is independent of + // solver membership and cannot delay newly visible thumbnails. + box.button.disabled=true;box.button.style.pointerEvents='none'; + box.button.setAttribute('aria-hidden','true'); + for(const node of [box.button,box.tether]){ + if(!node)continue; + if(matchMedia('(prefers-reduced-motion: reduce)').matches){node.remove();continue;} + const opacity=getComputedStyle(node).opacity; + const fade=node.animate([{opacity},{opacity:0}],{duration:100,fill:'forwards'}); + fade.onfinish=()=>node.remove(); + } +} +function reanchorThumbnails(points,width,height){ + const kept=[]; + for(const box of mapThumbLayout){ + const point=points[box.id];if(!point){retireThumbnail(box);continue;} + const dx=box.x-box.anchorX,dy=box.y-box.anchorY; + if(box.guideX!==undefined){box.guideX+=point[0]-box.anchorX;box.guideY+=point[1]-box.anchorY;} + box.anchorX=point[0];box.anchorY=point[1];box.x=point[0]+dx;box.y=point[1]+dy; + box.left=box.x-box.width/2;box.top=box.y-box.height/2; + if(box.left<0||box.top<0||box.left+box.width>width||box.top+box.height>height){retireThumbnail(box);continue;} + kept.push(box); + } + mapThumbLayout=kept;paintThumbnailMotion(); +} +function setThumbnailAnchors(){ + $('map-thumbnail-layer').classList.toggle('hide-thumb-anchors',!$('map-photo-anchors').checked); +} +function scheduleMapThumbnails(){ + clearTimeout(mapThumbTimer);stopThumbnailMotion();mapThumbController?.abort();++mapThumbGeneration; + const size=Number($('map-photo-size').value),density=Number($('map-photo-density').value),overlap=Number($('map-photo-overlap').value); + const key=[size,$('map-photo-labels').checked,$('map-photo-push').checked,$('map-photo-aspect').checked,$('projection-plane').value].join(':'); + if($('map-photos').checked&&mapThumbCase===data&&mapThumbKey===key){ + const rect=projectionCanvas.getBoundingClientRect(); + reanchorThumbnails(projectionHits.map(([x,y])=>[x*rect.width/1200,y*rect.height/600]),rect.width,rect.height); + mapThumbOffsets=new Map(mapThumbLayout.map(b=>[b.id,[b.x-b.anchorX,b.y-b.anchorY]])); + }else{mapThumbOffsets.clear();mapThumbLayout=[];$('map-thumbnail-layer').replaceChildren();} + mapThumbCase=data;mapThumbKey=key; + $('map-size-value').value=size+' px';$('map-density-value').value=density+' / MP';$('map-overlap-value').value=overlap+'%'; + if(!$('map-photos').checked){$('map-photo-status').textContent='Enable map thumbnails to interpret numeric class IDs as GBIF taxa. Push apart connects displaced images to their fixed prototype points.';return;} + if(data.metadata.synthetic){$('map-photo-status').textContent='Synthetic case: no GBIF photo requests.';return;} + $('map-photo-status').textContent='Updating visible thumbnails…'; + mapThumbTimer=setTimeout(renderMapThumbnails,180); +} +async function renderMapThumbnails(){ + const generation=mapThumbGeneration,caseData=data; + mapThumbController=new AbortController();const signal=mapThumbController.signal; + const rect=projectionCanvas.getBoundingClientRect(),size=Number($('map-photo-size').value); + const config={width:rect.width,height:rect.height,size,density:Number($('map-photo-density').value),overlap:Number($('map-photo-overlap').value)/100,labels:$('map-photo-labels').checked,push:$('map-photo-push').checked,aspect:$('map-photo-aspect').checked}; + if(config.aspect)config.aspects=data.names.map(id=>{const album=photoAlbums.get(id),photo=album?.photos[(photoChoices.get(id)||0)%(album?.photos.length||1)],image=photo&&mapThumbImages.get(photo.image_path);return image?.naturalWidth/image?.naturalHeight;}); + const points=projectionHits.map(([x,y])=>[x*rect.width/1200,y*rect.height/600]); + // Selected class first, then its true neighbours; other classes have stable + // mixed row order so dense regions do not win simply by checkpoint ordering. + const remaining=data.names.map((_,i)=>i).sort((a,b)=>(Math.imul(a,2654435761)>>>0)-(Math.imul(b,2654435761)>>>0)); + const priority=[selected,...data.neighbours[selected],...remaining].filter(i=>photoNumber(data.names[i])&&!mapThumbFailures.has(data.names[i])); + const {boxes,budget}=thumbnailLayout(points,priority,{...config,push:false,unknownOverlap:config.aspect&&!config.push?0:config.overlap}); + const ranks=new Map(boxes.map((b,i)=>[b.id,i])); + const retained=new Map(); + for(const box of mapThumbLayout){ + if(ranks.has(box.id)&&box.photoChoice===(photoChoices.get(data.names[box.id])||0))retained.set(box.id,box); + else{retireThumbnail(box);} + } + mapThumbLayout=[...retained.values()].sort((a,b)=>ranks.get(a.id)-ranks.get(b.id)); + + let motionFinished=false; + let loaded=0,unavailable=0; + const status=()=>{$('map-photo-status').textContent=`${loaded} images loaded · ${motionFinished?mapThumbLayout.length:boxes.length} visible slots · budget ${budget} for this viewport. ${config.push?`Push apart: ${motionFinished?`${mapThumbRest?.reason||'resting'} · ${mapThumbRest?.pairs||0} overlapping pairs retained`:'adjusting rectangle contacts'}. Images move at most ${2*size} px.`:`Maximum pairwise rectangle overlap ${Math.round(config.overlap*100)}%.`} ${unavailable} unavailable. Hover for names and credits; click to inspect. Prototype coordinates and neighbour ranks stay fixed.`;}; + if(!boxes.length){status();return;} + try{if(boxes.some(b=>!retained.has(b.id))){const health=await fetch('/api/health',{signal});if(!health.ok||!(await health.json()).photo_api)throw Error('Photo server unavailable');}} + catch(error){if(!signal.aborted)$('map-photo-status').textContent='Map photos require the local GBIF photo server. Numerical views remain available.';return;} + const loadBox=async box=>{ + if(signal.aborted||generation!==mapThumbGeneration||data!==caseData)return; + const id=data.names[box.id]; + if(retained.has(box.id)){loaded++;const old=retained.get(box.id);old.button.style.zIndex=String(boxes.length-ranks.get(box.id));old.button.style.borderColor=box.id===selected?'#c2344b':data.neighbours[selected].includes(box.id)?'#087e8b':'#7c90a3';return;} + try{ + let album=photoAlbums.get(id); + if(!album){const response=await fetch('/api/gbif/'+id,{signal});album=await response.json();if(!response.ok)throw Error(album.error||'Photo lookup failed');photoAlbums.set(id,album);} + if(signal.aborted||generation!==mapThumbGeneration)return; + if(!album.photos.length){mapThumbFailures.add(id);unavailable++;status();return;} + const choice=(photoChoices.get(id)||0)%album.photos.length,photo=album.photos[choice]; + let image=mapThumbImages.get(photo.image_path)?.cloneNode(); + const button=photoText('button','','map-thumb');button.type='button';button.style.left=box.x+'px';button.style.top=box.y+'px';button.style.width=box.width+'px';button.style.height=box.height+'px';button.style.zIndex=String(boxes.length-boxes.indexOf(box));button.style.borderColor=box.id===selected?'#c2344b':data.neighbours[selected].includes(box.id)?'#087e8b':'#7c90a3';button.setAttribute('aria-label',`Inspect ${album.display_name}, class ${id}`);button.onclick=()=>selectClass(box.id);button.onfocus=()=>showMapPhotoCredit(box.id); + button.classList.toggle('image-only',!config.labels);button.classList.toggle('original-aspect',config.aspect);button.title=`${album.display_name} · class ${id}`; + if(!image){image=new Image();image.decoding='async';} + image.draggable=false;image.alt=album.display_name;image.style.height=size+'px'; + button.append(image);if(config.labels)button.append(photoText('span',id)); + const tether=thumbnailTether(box,config.push); + const okay=(image.complete&&image.naturalWidth>0)||await loadReferenceImage(image,photo.image_path,signal); + if(signal.aborted||generation!==mapThumbGeneration)return; + if(okay){ + const footprint=thumbnailFootprint(size,config.labels,config.aspect?image.naturalWidth/image.naturalHeight:1); + box.width=footprint.width;box.height=footprint.height;box.left=box.x-box.width/2;box.top=box.y-box.height/2; + button.style.width=box.width+'px';button.style.height=box.height+'px';image.style.height=footprint.imageHeight+'px'; + box.button=button;box.tether=tether;box.photoChoice=photoChoices.get(id)||0;box.readyAt=performance.now()+80; + const offset=config.push?mapThumbOffsets.get(box.id):null; + if(offset){box.x=Math.max(box.width/2,Math.min(config.width-box.width/2,box.anchorX+offset[0]));box.y=Math.max(box.height/2,Math.min(config.height-box.height/2,box.anchorY+offset[1]));box.left=box.x-box.width/2;box.top=box.y-box.height/2;button.style.left=box.x+'px';button.style.top=box.y+'px';} + if(tether)$('map-thumbnail-layer').append(tether);$('map-thumbnail-layer').append(button); + const bounds=button.getBoundingClientRect();box.width=bounds.width;box.height=bounds.height;box.left=box.x-box.width/2;box.top=box.y-box.height/2; + mapThumbLayout.push(box);mapThumbLayout.sort((a,b)=>ranks.get(a.id)-ranks.get(b.id)); + if(config.push)animateThumbnails(config); + loaded++;mapThumbImages.delete(photo.image_path);mapThumbImages.set(photo.image_path,image);while(mapThumbImages.size>128)mapThumbImages.delete(mapThumbImages.keys().next().value);} + else{button.remove();tether?.remove();mapThumbLayout=mapThumbLayout.filter(b=>b!==box);mapThumbFailures.add(id);unavailable++;} + status();if(box.id===selected)showMapPhotoCredit(selected); + }catch(error){if(signal.aborted)return;mapThumbFailures.add(id);unavailable++;status();} + }; + // Bound in-flight work; a slow image no longer stalls every later class. + let next=0; + await Promise.all(Array.from({length:Math.min(4,boxes.length)},async()=>{ + while(next{mapThumbFailures.clear();scheduleMapThumbnails();}; +$('map-photo-anchors').oninput=setThumbnailAnchors;setThumbnailAnchors(); +new ResizeObserver(scheduleMapThumbnails).observe(projectionCanvas); +scheduleMapThumbnails(); diff --git a/pyproject.toml b/pyproject.toml index af5464e4..8deb80b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ mt_predict = "mini_trainer.predict:run" mt_export = "mini_trainer.export:run" mt_htrain = "mini_trainer.hierarchical.train:run" mt_hpredict = "mini_trainer.hierarchical.predict:run" +mt_explore = "mini_trainer.visualization.prototype_space.launch:run" mt_plot_dendrogram = "mini_trainer.visualization.dendrogram:run" mt_create_combinations = "mini_trainer.utils.create_combinations:run" mt_create_data_index = "mini_trainer.utils.create_data_index:run" @@ -77,6 +78,7 @@ dev = [ ] [project.optional-dependencies] +explorer = ["scipy", "scikit-learn>=1.5"] recommended = [ "tensorboard", "setuptools<=81.0.0", # Temporary fix to the error "ModuleNotFoundError: No module named 'pkg_resources'" from tensorboard @@ -93,6 +95,7 @@ all = [ "mini_trainer[bioclip]", "mini_trainer[transformers]", "mini_trainer[export]", + "mini_trainer[explorer]", ] quantization = ["torchao>=0.17,<0.18"] export = ["onnx>=1.17", "onnxscript>=0.3", "onnxruntime>=1.20"] diff --git a/tests/utils/test_prototype_launcher.py b/tests/utils/test_prototype_launcher.py new file mode 100644 index 00000000..27fed569 --- /dev/null +++ b/tests/utils/test_prototype_launcher.py @@ -0,0 +1,99 @@ +"""Reusable explorer exports and browser-selected checkpoints.""" + +import json +import re +import subprocess +import sys +import time +from urllib.error import HTTPError +from urllib.request import Request, urlopen + +import pytest +import torch +from torch import nn + +pytest.importorskip("scipy") + + +def checkpoint(path): + layer = nn.Linear(5, 4) + state = {f"classifier.linear.{key}": value for key, value in layer.state_dict().items()} + state["classifier._extra_state"] = { + "normalized": False, + "classifier_class": "mini_trainer.modeling.classifier:Classifier", + "cls2idx": {"alpha": 2, "beta": 0, "gamma": 3, "delta": 1}, + } + torch.save(state, path) + return layer + + +def test_export_public_api_and_assets(tmp_path): + from mini_trainer.visualization.prototype_space.explore import load_prototypes + from mini_trainer.visualization.prototype_space.launch import generate + + path = tmp_path / "weights.pt" + layer = checkpoint(path) + weight, names, _, _ = load_prototypes(path) + torch.testing.assert_close(weight, layer.weight) + assert names == ["beta", "delta", "alpha", "gamma"] + html = generate(path, tmp_path / "report", angular=False) + assert html.is_file() + assert "__REPORT_DATA__" not in html.read_text() + assert "function animateThumbnails" in html.read_text() + report = json.loads(html.with_name("report-data.json").read_text()) + assert list(report) == ["Production checkpoint"] + assert report["Production checkpoint"]["names"] == names + + +def test_browser_upload_and_recovery(tmp_path): + checkpoint_path = tmp_path / "weights.pt" + checkpoint(checkpoint_path) + log = tmp_path / "server.log" + with log.open("w") as output: + process = subprocess.Popen( + [sys.executable, "-m", "mini_trainer.visualization.prototype_space", "--no-browser", "--pca-only"], + stdout=output, + stderr=subprocess.STDOUT, + ) + try: + deadline = time.monotonic() + 30 + address = None + while time.monotonic() < deadline: + match = re.search(r"http://localhost:\d+/", log.read_text()) + if match: + address = match.group() + break + assert process.poll() is None, log.read_text() + time.sleep(0.05) + assert address, log.read_text() + with urlopen(address) as response: + page = response.read().decode() + token = re.search(r"'X-Explorer-Token':'([^']+)'", page).group(1) + with pytest.raises(HTTPError) as rejected: + urlopen(Request(address + "api/weights", data=b"bad")) + assert rejected.value.code == 403 + + def upload(content): + with urlopen(Request(address + "api/weights", data=content, headers={"X-Explorer-Token": token})) as response: + assert response.status == 202 + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + with urlopen(address + "api/session") as response: + status = json.load(response) + if status["state"] in {"ready", "error"}: + return status + time.sleep(0.1) + pytest.fail("generation timed out") + + assert upload(b"not a checkpoint")["state"] == "error" + status = upload(checkpoint_path.read_bytes()) + assert status["state"] == "ready", status + with urlopen(address + "report-data.json") as response: + data = json.load(response) + assert data["Production checkpoint"]["names"] == ["beta", "delta", "alpha", "gamma"] + with pytest.raises(HTTPError) as missing: + urlopen(address + "selected-weights.pt") + assert missing.value.code == 404 + finally: + process.terminate() + process.wait(timeout=10) diff --git a/uv.lock b/uv.lock index 3d920fb7..224166e7 100644 --- a/uv.lock +++ b/uv.lock @@ -262,6 +262,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, ] +[[package]] +name = "cloudpickle" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -1132,6 +1141,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "joblib" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cloudpickle" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/1d/537ab090f302b838943a1b56497dd53059b9a9b46a074936470173a2e207/joblib-1.6.0.tar.gz", hash = "sha256:2ccc96785b12046c08fd6d55839c12857831b54a3c1673ffadd2f04bfc4eda03", size = 327903, upload-time = "2026-08-31T09:39:04.122Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/53/84099323c2ec4be98d935f63c033ac4151ee83836ca1050ede3b3aadf155/joblib-1.6.0-py3-none-any.whl", hash = "sha256:3dbbf9f6e4b592a2357b854608e980fe6390d131d7a82f011a377ef2ebef7aba", size = 306115, upload-time = "2026-08-31T09:39:02.298Z" }, +] + [[package]] name = "jupyter-client" version = "8.8.0" @@ -1452,6 +1473,7 @@ all = [ { name = "pyarrow" }, { name = "pycirclize" }, { name = "pyremotedata" }, + { name = "scikit-learn" }, { name = "scipy" }, { name = "setuptools" }, { name = "tensorboard" }, @@ -1480,6 +1502,10 @@ cu132 = [ { name = "torch", version = "2.12.0+cu132", source = { registry = "https://download.pytorch.org/whl/cu132" } }, { name = "torchvision", version = "0.27.0+cu132", source = { registry = "https://download.pytorch.org/whl/cu132" } }, ] +explorer = [ + { name = "scikit-learn" }, + { name = "scipy" }, +] export = [ { name = "onnx" }, { name = "onnxruntime" }, @@ -1525,6 +1551,7 @@ requires-dist = [ { name = "ipywidgets", marker = "extra == 'notebook'" }, { name = "matplotlib" }, { name = "mini-trainer", extras = ["bioclip"], marker = "extra == 'all'" }, + { name = "mini-trainer", extras = ["explorer"], marker = "extra == 'all'" }, { name = "mini-trainer", extras = ["export"], marker = "extra == 'all'" }, { name = "mini-trainer", extras = ["notebook"], marker = "extra == 'all'" }, { name = "mini-trainer", extras = ["recommended"], marker = "extra == 'all'" }, @@ -1540,6 +1567,8 @@ requires-dist = [ { name = "pycirclize", marker = "extra == 'recommended'", specifier = ">=1.10.1" }, { name = "pyremotedata", marker = "extra == 'notebook'" }, { name = "pyyaml", specifier = ">=6.0.3" }, + { name = "scikit-learn", marker = "extra == 'explorer'", specifier = ">=1.5" }, + { name = "scipy", marker = "extra == 'explorer'" }, { name = "scipy", marker = "extra == 'recommended'" }, { name = "setuptools", marker = "extra == 'recommended'", specifier = "<=81.0.0" }, { name = "tensorboard", marker = "extra == 'recommended'" }, @@ -1559,7 +1588,7 @@ requires-dist = [ { name = "transformers", marker = "extra == 'transformers'" }, { name = "wandb", marker = "extra == 'recommended'" }, ] -provides-extras = ["recommended", "all", "quantization", "export", "minimal", "notebook", "bioclip", "transformers", "timm", "cpu", "cu126", "cu130", "cu132"] +provides-extras = ["explorer", "recommended", "all", "quantization", "export", "minimal", "notebook", "bioclip", "transformers", "timm", "cpu", "cu126", "cu130", "cu132"] [package.metadata.requires-dev] dev = [ @@ -1619,6 +1648,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, ] +[[package]] +name = "narwhals" +version = "2.26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/68/5351e34623d253423240ea7de3f8fc74fa8ab14b1ab3c0ec4ac8997413c9/narwhals-2.26.0.tar.gz", hash = "sha256:6b9cadca82f375c7e4cf584fdc86ca25da54827307a9c58f94547ee6104b82dd", size = 686970, upload-time = "2026-09-08T13:32:08.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/b5/1b84b2c784db76d69442334bc8b8748c840f13ca53be086f4f250ad4a0bc/narwhals-2.26.0-py3-none-any.whl", hash = "sha256:29326d74f107c347fd1009bd58e38d9f7c7c5b51e6de97bc93dbc325d9038b54", size = 474034, upload-time = "2026-09-08T13:32:07.159Z" }, +] + [[package]] name = "nest-asyncio" version = "1.6.0" @@ -3115,6 +3153,57 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" }, ] +[[package]] +name = "scikit-learn" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "narwhals" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/eb/eaf5e07fcc0da7149b0e084f24e54edd7441b9a89ce7e034032ae97fe3a0/scikit_learn-1.9.1.tar.gz", hash = "sha256:629cada3e33e2b9bf376cdc7614a47a4140b8aedc1d836579e359736fbd82977", size = 7786908, upload-time = "2026-09-10T18:34:04.679Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/a7/25f0a43d2fde306e8ef45f45121192f687b79beaf4bae8c21607c46c5e63/scikit_learn-1.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0c0f8b5d09b44101cea2767f300680bada1ea27f976fe4b48b83950a4f55a49a", size = 8775209, upload-time = "2026-09-10T18:32:42.804Z" }, + { url = "https://files.pythonhosted.org/packages/60/ea/57e57539ce175d774fc291ed091b0a6d756854b92cd92554c6bb4d0ae498/scikit_learn-1.9.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:8c14ce41d561f7749f990b41d6703fe02c4669fbc485e598e069e0a1967b488e", size = 8295541, upload-time = "2026-09-10T18:32:45.069Z" }, + { url = "https://files.pythonhosted.org/packages/78/2b/5721a174406bfba49bce20ae997b3b64cf355c3f623a2638284ab6a82156/scikit_learn-1.9.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4c20a6c017d820faa7ac8c783e3d0c6a9a2e297bf9f55332ca17cdf7fd4d04d", size = 8871592, upload-time = "2026-09-10T18:32:46.999Z" }, + { url = "https://files.pythonhosted.org/packages/8e/57/a50162f3d29feb979ab6347c6debda506dfb525bcff3c50dd17606651c7e/scikit_learn-1.9.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e5d7b18a5b9dca241a74695f3275fa4c895a9dadc72b3d8df5fa9d1083c9b83e", size = 9166043, upload-time = "2026-09-10T18:32:49.343Z" }, + { url = "https://files.pythonhosted.org/packages/72/8d/27c054166bac671770d1ea0ef7716134fe8119c0df224a58c89fd735a61c/scikit_learn-1.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:4b59abb30618121cc46b45972d6bf53a7128b4df4cd346c6ca6f4d5f9031e49c", size = 8262238, upload-time = "2026-09-10T18:32:51.679Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d6/493086006ea0c68ad62c40a8dece1961b61bf503f45400f133d47f56e5be/scikit_learn-1.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:d5945a2908be62350e2978344e62b56c1552c2ca4f844ebf6277c94944d647dd", size = 7902102, upload-time = "2026-09-10T18:32:53.67Z" }, + { url = "https://files.pythonhosted.org/packages/bb/8d/b60d5e7354ff0ff5cc9400e60273696589d87a30b8b2235886a76d80d062/scikit_learn-1.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c2b312fd8c02951a364fa120ea08c1cec10d863466bf1701b013152d7537835", size = 8739300, upload-time = "2026-09-10T18:32:55.483Z" }, + { url = "https://files.pythonhosted.org/packages/2f/81/3c6392c03665d2899457a76e535a9a6f597dddddf3220fd2e1d790da88c5/scikit_learn-1.9.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:61cd968ab831a76d0ecbaf0347ab2270268716da28f94fd022497e3d6f205f13", size = 8262364, upload-time = "2026-09-10T18:32:57.966Z" }, + { url = "https://files.pythonhosted.org/packages/0f/35/a15b8653499692879821301d48059376d6e68e8b65cd0f22d19b6ee83cd9/scikit_learn-1.9.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5990f9c69e431bfaddcde1a6d7c5355243e026bc9b9e560c13893b90dab53fb4", size = 8823698, upload-time = "2026-09-10T18:33:00.632Z" }, + { url = "https://files.pythonhosted.org/packages/23/e5/688703d357e5393f708d98eb189fd415ae69e39f6de03c6bd4005aef6118/scikit_learn-1.9.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:55e79d6e9b0923f1a978179822bd43d7f5543f45e970a00fe861f43486380aba", size = 9121732, upload-time = "2026-09-10T18:33:02.825Z" }, + { url = "https://files.pythonhosted.org/packages/96/45/a10add34c08184d373be9384660c75758128ca881ed27b503b6f6a742478/scikit_learn-1.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:2070f271e5375dc42c6bb93b461ab1c0aa5841d4009267e0cfd95a39dca94a43", size = 8237244, upload-time = "2026-09-10T18:33:05.26Z" }, + { url = "https://files.pythonhosted.org/packages/9e/08/7a89bcdadd1fff0d464d01056417b646c9abcbc54f7297a0a1203bba5ebb/scikit_learn-1.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:613f0a783ca05aa844a4e1ac42d48425058f2c52be73f40f8cd98b7cd111acd6", size = 7875398, upload-time = "2026-09-10T18:33:07.506Z" }, + { url = "https://files.pythonhosted.org/packages/64/e3/b58e45082dcf3dcf0eb1192ee03545ec43d8c98441dfe20e88afca8442ce/scikit_learn-1.9.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5d117952769b563067656784e03c75a2d8235a7a05cf7fffa78a311e75aac08", size = 8747414, upload-time = "2026-09-10T18:33:10.698Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ed/d68115577c8b42b0442ebd8180945d4008880a33176094640e00b585128e/scikit_learn-1.9.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:8893bc6331f60f18d4ac75e12ed356e2dcf6a564bf767918b5b7ca54c8c8be49", size = 8279175, upload-time = "2026-09-10T18:33:12.846Z" }, + { url = "https://files.pythonhosted.org/packages/c4/6c/06c7eb61a438e389cbf3f7210897069bec5a883dbe03c189ae792781e11a/scikit_learn-1.9.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5492cf2df5226691c32611de8734bcf42148c6547ae53c7f4e6b847793addc0", size = 8881706, upload-time = "2026-09-10T18:33:14.741Z" }, + { url = "https://files.pythonhosted.org/packages/86/4e/0bab75490ca4b85fad8388739c7ebc71d9db553f8c69e39943ee8db0aaae/scikit_learn-1.9.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993d332ff80e62efae9e39603b7e872297c418d780f01a01855269a3489c950f", size = 9152030, upload-time = "2026-09-10T18:33:17.436Z" }, + { url = "https://files.pythonhosted.org/packages/9a/13/31c6f8ba1b7eecef9dd9558576c752d2ec5785fd0e456bb9fc59305be23f/scikit_learn-1.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:ca9051447455dae341d4d591eece7deb2d8e3d1020298fc87a81fc51e4da8f53", size = 8377024, upload-time = "2026-09-10T18:33:19.941Z" }, + { url = "https://files.pythonhosted.org/packages/62/e6/6d3cb8a45f5228f915acd66b819dd6b8232ccbe24532f51d278e3991df31/scikit_learn-1.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:90de6573f733a9fb79476ff1371af52a397d41c8b35f9146e20923db010d67b6", size = 8014196, upload-time = "2026-09-10T18:33:22.126Z" }, + { url = "https://files.pythonhosted.org/packages/66/6e/6befb2d5961490d18d9dbc16a5df37aa121d08bc9893316a6a363977a903/scikit_learn-1.9.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7b5cad1624de8b75e5b9ccb7b0ce1ff1d01306340a3efc56d5529c5ba92392eb", size = 9066066, upload-time = "2026-09-10T18:33:24.396Z" }, + { url = "https://files.pythonhosted.org/packages/cb/18/11271f2f7db337db01f598e358721b1e83989407131272e5dd64214c28a8/scikit_learn-1.9.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:d137ce8a6142029fb5c35bd82f470c40cd9e760e5e2f7694b362c497c4ab3fa2", size = 8647654, upload-time = "2026-09-10T18:33:26.649Z" }, + { url = "https://files.pythonhosted.org/packages/e7/04/9c15d201e1b6a2e81b8215865df7646c5a360f560831769c8dc92ac1ab9a/scikit_learn-1.9.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66f852f7325b5070bc28329005aca76055a2def78faac039548ae889aeaa45a6", size = 8889070, upload-time = "2026-09-10T18:33:28.95Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5a/4cb6c85160af4a639e87a3b7bf8b1c25cfc3b504c5af710ca416a6dcfc5f/scikit_learn-1.9.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:748bcb0a4cc04aec470652c9e5ec68450948e867387e7dfade647107ade68d25", size = 9131227, upload-time = "2026-09-10T18:33:31.008Z" }, + { url = "https://files.pythonhosted.org/packages/00/0e/361440972ae3d19b90ea88a84791138a51de0e8432a770ba741b2c8d9ced/scikit_learn-1.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:38cd925e893e5539be704d5edc64dbe081aacdab6b89d8c2977c1f6a7a453ce5", size = 8683001, upload-time = "2026-09-10T18:33:33.188Z" }, + { url = "https://files.pythonhosted.org/packages/e2/8f/a9f405c5c0e2df6f343a871b40c97fb32969e3ccc38e3033dd118f3c261e/scikit_learn-1.9.1-cp314-cp314t-win_arm64.whl", hash = "sha256:b01e5b01735d38474127ca3f49319b592506225a87793b27559816b5c75cea39", size = 8257996, upload-time = "2026-09-10T18:33:35.343Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c5/74a83ea39cef7cd07f53e06cc1cf51e79f35df74f81db956835d59ec34b1/scikit_learn-1.9.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8dec64f31a6e0ec826aca6c1b39a51e16d946e400d4c0904316f3ca72ccfb825", size = 8747991, upload-time = "2026-09-10T18:33:37.4Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/cc93e8a7fe204e43d70e96e5eb89871643be20025eea05eb4fdaf19afe39/scikit_learn-1.9.1-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:e1b468241f4a7a9a7a0d6479ad3cc47681cc151a4046c530f2777c3d68f08942", size = 8279340, upload-time = "2026-09-10T18:33:39.705Z" }, + { url = "https://files.pythonhosted.org/packages/a2/61/0c6080f0d356fb966053009e7f25e9bff6cf74b0b16195ccf0c3757d1ae8/scikit_learn-1.9.1-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ca869d0080a5723cde2d5a8b54a2da1ff7e68735a9e9adb3da1243183a0fa01", size = 8881917, upload-time = "2026-09-10T18:33:41.912Z" }, + { url = "https://files.pythonhosted.org/packages/47/bb/98a31f10fffbd39edcc2f8bf4119b29652248bb110b7d45c84e68aa293ab/scikit_learn-1.9.1-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6754b7cabfc3df0b1f7b38f7a344f559bbae9d82f0ac5e3d48cccbd19fdcefdf", size = 9152805, upload-time = "2026-09-10T18:33:44.23Z" }, + { url = "https://files.pythonhosted.org/packages/f6/48/858ceff52213cfd97c0a069362071756bcb70b9fac9771b38d87c4cf7f17/scikit_learn-1.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:52cfdb1fed3a34362dbc0bd96f2e761a66fd5724d6901629f5a558f1f3bd9849", size = 8376719, upload-time = "2026-09-10T18:33:46.492Z" }, + { url = "https://files.pythonhosted.org/packages/2a/1e/5337a871bdea53effbd154b61429df048f2665653251de74a3bd8a6dea9e/scikit_learn-1.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:ae6571a4828c6f5019bcd2b4125e5b18c0af3dbc9c99726c891f45f41335ec8e", size = 8014341, upload-time = "2026-09-10T18:33:48.762Z" }, + { url = "https://files.pythonhosted.org/packages/0e/35/150383a42d83ec4c7b39f9c50bd68408ecf04c19fc30ea5198fa42e67d9c/scikit_learn-1.9.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:48fefd8eb42bd4eec3e2d348149368ccd6d71987e20c30706a56a24eb86a6e73", size = 9059953, upload-time = "2026-09-10T18:33:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/9f/dd/aa0d738808540f7eaacfab93e01982db8ef1c1c7473ef0ad38193e6aebd1/scikit_learn-1.9.1-cp315-cp315t-macosx_12_0_arm64.whl", hash = "sha256:09f4d73049cd63575157f6b1060e06a8c83a4bd3488dbfaeedf35ccba7aad712", size = 8646602, upload-time = "2026-09-10T18:33:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/7e/cc/687ae4214c2f598906c3b9fa5f86fbaf834b35e20360625528ff1b713f06/scikit_learn-1.9.1-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3da53831534214322d9cb240fa6f390b36cf69eba727a6d4bd3238677630d70", size = 8880366, upload-time = "2026-09-10T18:33:55.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/03/82215cb78ad1c513a4498777571fb28444621ad26ef636287551767b7732/scikit_learn-1.9.1-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caae15634feceafa2612566b109a3082d3293167fac388eedaf77bff66b51983", size = 9129640, upload-time = "2026-09-10T18:33:58.186Z" }, + { url = "https://files.pythonhosted.org/packages/d4/90/4b7af4efd7909a4a0524a9f18457e4eb2eb60616eff2c7627eda8e3cdceb/scikit_learn-1.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:ffbcbbbb44202fbe9bc64bced25a145759adb9ef010b3d37a8064958ac13df2a", size = 8678083, upload-time = "2026-09-10T18:34:00.354Z" }, + { url = "https://files.pythonhosted.org/packages/31/27/068e484d4b83004302e0d9cfc1faca69bcc010d76fbb66a642446095af1b/scikit_learn-1.9.1-cp315-cp315t-win_arm64.whl", hash = "sha256:800dd22dd87fe97dcea484c24e85dd93cf1734d86bd74e668ad18f7967f4d1b5", size = 8259434, upload-time = "2026-09-10T18:34:02.678Z" }, +] + [[package]] name = "scipy" version = "1.17.1" @@ -3281,6 +3370,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/c6/825dab04195756cf8ff2e12698f22513b3db2f64925bdd41671bfb33aaa5/tensorboard_data_server-0.7.2-py3-none-manylinux_2_31_x86_64.whl", hash = "sha256:ef687163c24185ae9754ed5650eb5bc4d84ff257aabdc33f0cc6f74d8ba54530", size = 6590363, upload-time = "2023-10-23T21:23:35.583Z" }, ] +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + [[package]] name = "timm" version = "1.0.27" From e639be80236ab1e1d2553ac2fc81fcc07a8d67a1 Mon Sep 17 00:00:00 2001 From: asgersvenning Date: Fri, 11 Sep 2026 10:32:54 +0200 Subject: [PATCH 17/34] docs: consolidate prototype explorer roadmap --- dev/prototype_space/README.md | 3 +- docs/hyperspherical-visualization.md | 12 ++++-- docs/prototype-explorer-roadmap.md | 55 ++++++++++++++++++++++++++++ docs/prototype-explorer.md | 3 ++ docs/roadmap.md | 2 + 5 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 docs/prototype-explorer-roadmap.md diff --git a/dev/prototype_space/README.md b/dev/prototype_space/README.md index da55bfd2..dae38aa8 100644 --- a/dev/prototype_space/README.md +++ b/dev/prototype_space/README.md @@ -273,7 +273,8 @@ exact local baseline submatrices and log-domain values against an independent `math.erfc` reference. Browser interaction checks additionally exercise case switching, class search, neighbour navigation, tree navigation and matrix modes. -Promising follow-ups after trying this prototype: +The [maintained roadmap](../../docs/prototype-explorer-roadmap.md) owns priority +and status. Supporting research questions include: - Evaluate log-domain clustering with explicit representation and compatibility decisions. Compare any changed tree against this preserved baseline before diff --git a/docs/hyperspherical-visualization.md b/docs/hyperspherical-visualization.md index dbe4cecd..d7c4f89d 100644 --- a/docs/hyperspherical-visualization.md +++ b/docs/hyperspherical-visualization.md @@ -442,20 +442,24 @@ unordered off-diagonal pairs and about 638 MB per dense float32 square matrix graphs, sampled diagnostics and periodic training-time summaries have different cost boundaries. Keep them explicit. -## 7. Recommended next increments +## 7. Research candidates -**First: an exact-radius anchor atlas.** It extends the current photo browser, +The [explorer roadmap](prototype-explorer-roadmap.md) owns current implementation +order and status. The candidates below retain the research rationale; their +original ordering is not a separate development queue. + +**An exact-radius anchor atlas.** It extends the current photo browser, answers a precise angular question and requires no new package. Add selectable bearings, angular rings, unresolved-bearing handling and original-neighbour overlays. Show that radii are exact while bearings are reduced. -**Second: a metric-controlled globe comparison.** Fit angular-input UMAP to a +**A metric-controlled globe comparison.** Fit angular-input UMAP to a plane and to spherical output, alongside the existing t-SNE. Add a small custom spherical-stress pilot if fidelity warrants it. Inspect both original-angle errors and neighbour recall before deciding whether a globe helps browsing. Use Three.js for a genuinely rotatable sphere, with the current thumbnail policy. -**Third: interactive great-circle/great-sphere slices.** These address the +**Interactive great-circle/great-sphere slices.** These address the partition question directly, complementing the neighbour atlas and compressed global map. Retain all prototype coefficients when evaluating winning regions; photo labels identify the classes appearing in the slice. diff --git a/docs/prototype-explorer-roadmap.md b/docs/prototype-explorer-roadmap.md new file mode 100644 index 00000000..56595909 --- /dev/null +++ b/docs/prototype-explorer-roadmap.md @@ -0,0 +1,55 @@ +# Prototype explorer roadmap + +Updated 2026-09-11. This is the maintained development order for the explorer; +research detail belongs in the [geometry review](hyperspherical-visualization.md), +and operation in the [usage guide](prototype-explorer.md). Later stages are +proposals, not active implementation commitments. + +## Delivered + +On `feature/prototype-space`, through `4763041` (not yet integrated into the +main development branch): + +- Reusable package, `mt_explore`, browser weight selection, and portable export. +- Baseline distance matrices and Ward tree; direct log-tail diagnostics and + configurable display clipping; linked class and neighbourhood inspection. +- PCA and angular t-SNE, with measured neighbourhood retention. +- GBIF photo labels, configurable footprints/culling, continuous arrival motion, + pan/zoom retention, and quick navigation exit fades. + +Evidence: real checkpoint diagnostics match the preserved baseline; 84 browser +checks, 12 affected Python tests, motion checks, static checks, and installed-wheel +CLI/file-picker checks passed. Current extraction supports float32 linear +`Classifier` and `HierarchicalClassifier` heads; dense analysis needs several GB +for the 12,632-class case. These checks do not establish full-suite/GPU coverage. + +## Ordered next work + +| Order / status | Increment | Done when | +| --- | --- | --- | +| 1 · Next | Reliable repeated exploration: progress/cancellation, analysis cache keyed by checkpoint and numerical configuration, saved viewer state. | Reopening avoids unnecessary analysis; cancellation leaves a usable session; loading, peak memory and frame motion are measured on the real case. | +| 2 · Planned | Local packing: angular cap counts, neighbour-radius distributions, mutual-neighbour links and taxonomy composition across scales. | Selected-class profiles and linked outliers agree with exact original-space calculations; unknown taxonomy remains explicit. | +| 3 · Planned | Anchor atlas, then two-anchor comparison: angular rings, selectable bearings and existing photo controls. | Radius matches the chosen baseline angular convention; bearing degeneracies and non-anchor distortion are disclosed and tested. | +| 4 · Research | Great-circle/great-sphere slices with winners, competing classes and margins. | Slice scores match direct high-dimensional evaluation; geometric regions are distinguished from the full classifier decision rule and global cell volumes. | +| 5 · Research | Compare further planar/spherical layouts; add a globe only if useful. | Near/far angular error, neighbour retention, seed stability, fit cost and display distortion justify a new option. | +| 6 · Later | Checkpoint trajectories, then held-out embedding overlays and observed confusion. | Comparisons align class IDs and distinguish rotation/layout changes from changes in relationships; empirical claims identify their sample data. | + +Take one bounded increment at a time. The next scientific feature is local packing +profiles followed by the anchor atlas; reliability work supports both. Integration +into the target branch requires review and validation of the combined result. + +## Rules for research and prioritization + +- Observed effective weights and existing distance functions are the baseline. + Synthetic cases test hypotheses and edge cases; they do not impose a packing prior. +- Preserve direct log-domain computation. Treat clipping as display configuration. + Alternative clustering inputs require explicit comparison with the retained tree. +- Do not infer spherical density or volume from projected density or slice area. +- Profile before replacing rendering or introducing approximations. Validate blocked, + sparse or sampled computations against exact results, disclosing approximation error. +- Broader head/dtype support needs an extraction contract and checkpoint parity tests. + It is a compatibility backlog item, not a reason to guess unsupported weights. + +Update this page with each completed increment or priority change: move delivered +work out of the queue, attach compact evidence, and revise the next step. Keep +experiment logs and detailed method comparisons in their existing linked documents. diff --git a/docs/prototype-explorer.md b/docs/prototype-explorer.md index 20050910..d508b861 100644 --- a/docs/prototype-explorer.md +++ b/docs/prototype-explorer.md @@ -6,6 +6,9 @@ class photos. The viewer uses the repository's effective-weight handling and class-distance functions. Projections and movable image labels do not change prototype coordinates or neighbour rankings. +See the [compact development and research roadmap](prototype-explorer-roadmap.md) +for delivered capabilities and ordered next steps. + Install the optional dependencies in your intended environment: ```bash diff --git a/docs/roadmap.md b/docs/roadmap.md index cd297396..7952ab37 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -4,6 +4,8 @@ The active quantization feature branch has a separate [execution roadmap](quantization-roadmap.md) covering measured bottlenecks, target-machine dependencies, integration gates and deferred work. +The prototype explorer has a focused [development and research roadmap](prototype-explorer-roadmap.md). + This is the implementation order. Each increment should leave the existing default training and prediction interfaces working and include its own validation evidence. Items below are planned unless explicitly marked delivered. From 0d5c6d38978c3977c41db086c541dcc28830e76d Mon Sep 17 00:00:00 2001 From: asgersvenning Date: Fri, 11 Sep 2026 10:38:01 +0200 Subject: [PATCH 18/34] docs: plan standalone browser inference and embedding inspection --- docs/prototype-explorer-roadmap.md | 32 +++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/docs/prototype-explorer-roadmap.md b/docs/prototype-explorer-roadmap.md index 56595909..43121a9c 100644 --- a/docs/prototype-explorer-roadmap.md +++ b/docs/prototype-explorer-roadmap.md @@ -23,6 +23,35 @@ CLI/file-picker checks passed. Current extraction supports float32 linear `Classifier` and `HierarchicalClassifier` heads; dense analysis needs several GB for the 12,632-class case. These checks do not establish full-suite/GPU coverage. +## Standalone client direction + +Target: browser inference and embedding inspection without a Python/PyTorch/CUDA +runtime. Export preparation may use mini_trainer once; the distributed viewer +loads an ONNX bundle, class/prototype metadata, and executable preprocessing. +The existing `.pt` picker remains a Python workflow, not this standalone path. + +Next feasibility gate: export actual prediction outputs plus the precise +preclassification embedding; verify preprocessing, embeddings and scores against +real-image reference results in ONNX Runtime Web (WASM first, then WebGPU). +CPU ONNX parity alone does not establish browser compatibility or performance. + +Then add image selection, original-space neighbours/margins and query overlays. +Save PCA means/bases for new points; define and validate placement into the fixed +t-SNE map separately. Anchor views can expose direct query-to-prototype angles. +Geometry explains relationships; observed probabilities retain actual head semantics. + +Deliver a static/installable viewer with locally selected models and images. +Precomputed global diagnostics are an initial delivery option; moving their +construction client-side is a separate milestone requiring blocked computation, +workers, memory/latency measurements and baseline numerical parity. Keep direct +log-domain semantics when porting diagnostics. GBIF networking/offline photo +availability must be explicit and independent of local inference. + +References: [current export contract](onnx.md), +[ONNX Runtime Web](https://onnxruntime.ai/docs/tutorials/web/), +[browser deployment](https://onnxruntime.ai/docs/tutorials/web/deploy.html). +This is planned work; no browser model inference is implemented or validated yet. + ## Ordered next work | Order / status | Increment | Done when | @@ -34,7 +63,8 @@ for the 12,632-class case. These checks do not establish full-suite/GPU coverage | 5 · Research | Compare further planar/spherical layouts; add a globe only if useful. | Near/far angular error, neighbour retention, seed stability, fit cost and display distortion justify a new option. | | 6 · Later | Checkpoint trajectories, then held-out embedding overlays and observed confusion. | Comparisons align class IDs and distinguish rotation/layout changes from changes in relationships; empirical claims identify their sample data. | -Take one bounded increment at a time. The next scientific feature is local packing +Take one bounded increment at a time. Qualify standalone inference before committing +to its runtime design. The next geometry feature is local packing profiles followed by the anchor atlas; reliability work supports both. Integration into the target branch requires review and validation of the combined result. From 0d733557a020258b68ca2e9f50666088b4e6d08b Mon Sep 17 00:00:00 2001 From: asgersvenning Date: Fri, 11 Sep 2026 10:42:24 +0200 Subject: [PATCH 19/34] docs: track epoch-26 prototype reference checkpoint --- docs/prototype-explorer-roadmap.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/prototype-explorer-roadmap.md b/docs/prototype-explorer-roadmap.md index 43121a9c..1c94d5ef 100644 --- a/docs/prototype-explorer-roadmap.md +++ b/docs/prototype-explorer-roadmap.md @@ -23,6 +23,13 @@ CLI/file-picker checks passed. Current extraction supports float32 linear `Classifier` and `HierarchicalClassifier` heads; dense analysis needs several GB for the 12,632-class case. These checks do not establish full-suite/GPU coverage. +Current real-data reference: `tmp/best_global-lepi-production-w32-1_epoch26.pt` +(training progress reported as 26/30 epochs), SHA-256 +`abb9f66d95fe867bd31847cb33782a353b1d3faf4c79c4b10d93037f33fe61f4`. +Verified extraction: 12,632 × 1,280 float32 normalized prototypes, zero biases, +and unchanged class ordering/hierarchy relative to epoch 4. Retain epoch 4 for +longitudinal comparisons. Browser inference parity is still an open gate. + ## Standalone client direction Target: browser inference and embedding inspection without a Python/PyTorch/CUDA From a88ef8eefab9e949a0e54e6b3eb399128a2c6646 Mon Sep 17 00:00:00 2001 From: asgersvenning Date: Fri, 11 Sep 2026 10:48:10 +0200 Subject: [PATCH 20/34] docs: define standalone explorer implementation and goal gates --- docs/prototype-browser-implementation.md | 183 +++++++++++++++++++++++ docs/prototype-explorer-roadmap.md | 3 + 2 files changed, 186 insertions(+) create mode 100644 docs/prototype-browser-implementation.md diff --git a/docs/prototype-browser-implementation.md b/docs/prototype-browser-implementation.md new file mode 100644 index 00000000..ad4f8ebb --- /dev/null +++ b/docs/prototype-browser-implementation.md @@ -0,0 +1,183 @@ +# Standalone prototype explorer: implementation plan + +Status: ready for goal setting; implementation has not started. This plan defines +execution and acceptance for the standalone direction in the +[compact roadmap](prototype-explorer-roadmap.md). It does not create an active goal. + +## Goal text + +> Build and validate a standalone prototype explorer that loads a mini_trainer +> export bundle and user-selected images, runs model inference in the browser, +> and explains predictions through embeddings in the model's prototype space. +> The distributed tool must require no Python, PyTorch, CUDA, or inference backend. +> Support local preprocessing, predictions, embedding-to-prototype diagnostics, +> query placement, and generation of the existing global exploration views. +> One-time model export may use mini_trainer. Precomputed diagnostics may accelerate +> opening a bundle, but must not be the only way to obtain the global views. +> Use the epoch-26 production checkpoint and existing numerical functions as the +> reference, preserve current explorer interactions, and provide reproducible +> browser parity, performance, offline-operation and installation evidence. + +## Reference and boundaries + +- Checkpoint: `/home/asger/mini_trainer/tmp/best_global-lepi-production-w32-1_epoch26.pt`. + SHA-256: `abb9f66d95fe867bd31847cb33782a353b1d3faf4c79c4b10d93037f33fe61f4`. +- Verified prototype shape: 12,632 × 1,280; normalized float32 effective rows, + zero biases. Epoch 4 remains a class-aligned comparison, not a replacement model. +- Initial supported heads: the explorer's current `Classifier` and + `HierarchicalClassifier` linear heads. Reject unsupported contracts explicitly. +- Start in `/home/asger/mini_trainer/.worktrees/prototype-space`, on + `feature/prototype-space`; revalidate HEAD and status before work. Further + concurrent implementation requires individually assigned worktrees and scopes. +- Preserve current Python entry points and training/export defaults. Export is an + authoring step; the browser consumes the resulting bundle, not arbitrary `.pt`. +- No architecture substitution, calibration claim, assumed packing distribution, + or unlabelled approximation. Browser inference does not establish GPU-training + parity or pixel-attribution explainability. + +## Architecture and deliverables + +Extend the existing exporter and explorer; avoid a second classifier implementation. +Keep authoring in Python, and inference/analysis in browser workers with a static +HTML/JS/WASM distribution. Prefer WASM CPU qualification first; add WebGPU only +with provider-specific parity and capability checks. Pin runtime assets locally. + +Define a versioned bundle with: + +| Artifact | Required content | +| --- | --- | +| Model | ONNX graph and every external tensor-data file; actual evaluation outputs plus a named preclassification embedding output. | +| Manifest | File hashes, checkpoint identity, output-tree semantics, effective class/hierarchy order, shapes/dtypes, embedding stage, preprocessing contract, supported runtime versions. | +| Prototypes | Binary effective weights, biases and necessary score metadata; retain magnitudes separately from directional geometry. | +| Projection state | PCA centering vector and basis; optional global coordinates/diagnostics with method, seed and numerical-configuration provenance. | +| Verification fixture | Legally usable real-image references or reproducible local fixture instructions, hashes, preprocessed tensors, embeddings and actual output tensors. | + +Allow selection of the complete bundle through the UI, including external ONNX +data. Choose and test a container/loading format in milestone 1; do not assume a +single ONNX file is a complete deployment. Keep large tensors binary rather than +embedding extra copies in JSON. No browser-to-server model/image upload. + +## Ordered implementation milestones + +### 1. Contract and reference fixture + +Inspect the epoch-26 architecture and exact evaluation preprocessing using the +existing checkpoint loader. Identify the embedding after head preclassification, +including any hidden transform and normalization; do not substitute raw backbone +features. Locate suitable real validation images locally and record their hashes. +Missing validation data must be reported, never replaced by synthetic-only evidence. + +Implement bundle schema validation and small flat/hierarchical fixtures. Define +explicit unsupported-input and version errors. Record the baseline environment, +model outputs and extraction semantics before changing export code. + +**Gate:** fixture outputs reproduce the current model, and every bundle field has +an identified producer and browser consumer. + +### 2. Export predictions plus embeddings + +Extend `mini_trainer/modeling/onnx.py` with an opt-in, export-compatible path for +both output kinds. Reuse the actual forward and do not depend on the training-only +`EmbeddingContext`: export currently rejects active training/supervision contexts. +Avoid a duplicate backbone pass. Preserve hierarchical output structure and masks. + +Add authoring support through the existing export/explorer commands, with weights +as the only required model argument. Extract executable preprocessing from supported +transforms; fail with a specific unsupported-transform error instead of guessing. + +**Gate:** real images and batches 1/2/4 match the PyTorch reference for predictions +and embeddings. Start with existing export tolerances (`rtol=1e-4`, `atol=1e-5`); +report error distributions and ranking differences. Any tolerance revision requires +an explained numerical investigation. Source checkpoint and model state stay intact. + +### 3. Browser inference qualification + +Load the real exported model with ONNX Runtime Web in a worker. First compare using +identical preprocessed tensors, isolating runtime errors from image decoding errors. +Then implement and verify image decoding, orientation, channel order, resizing, +cropping, scaling and normalization end to end. Include portrait/landscape images +and relevant alpha/orientation cases. General browser canvas resizing is not assumed +to reproduce the repository transform. + +**Gate:** WASM inference passes real-image parity; WebGPU is enabled only after its +own checks. Record runtime/browser versions, cold load, warm latency and peak memory. +Unsupported providers produce an actionable result and a verified fallback. + +### 4. Inference and geometric explanation UI + +Add model/image selection, progress, cancellation, bounded image batches, prediction +inspection and query thumbnails. Show actual head scores with their semantics. +Compute query-to-prototype geometry using the existing clamp/self-pair conventions; +port log-domain operations with independent tail/extreme-value tests. Query images +are not automatically class members or labelled correctly. + +Show angular neighbours, competing predictions, score margins and neighbourhood +profiles; distinguish geometric proximity from the full classification rule. + +**Gate:** browser calculations agree with repository references; latest selection +wins under rapid changes; cancelled work cannot publish stale results; loading does +not block navigation or restart thumbnail motion. + +### 5. Place inference embeddings in the views + +Implement fixed-transform PCA placement from saved means/bases and direct angular +anchor inspection. Add a separately validated method for inserting queries into a +fixed t-SNE layout; keep prototype coordinates fixed and disclose placement error. +Do not place images at predicted-class coordinates or label neighbour interpolation +as exact t-SNE projection. Evaluate poor as well as favourable query cases. + +**Gate:** PCA/anchor geometry matches direct computation; nonlinear placement has +measured neighbourhood/distance distortion; query selection links maps, neighbour +inspection and prototype/class photos without changing the underlying prototypes. + +### 6. Generate global diagnostics client-side + +Port the computations necessary for the current matrix summaries, Ward dendrogram, +PCA and angular t-SNE to workers/WASM/WebGPU as appropriate. Use blocked data access, +bounded allocations, persistent caches, cancellation and progress. Estimate real +peak allocations before running the full case. A single dense float32 matrix for +this model is about 638 MB; several simultaneous copies are not an acceptable default. + +**Gate:** the epoch-26 bundle can construct the global views without precomputed +results or a Python service on a documented reference machine. Distances and log tails +match baseline tolerances; tie/order differences in linkage are explained and tested. +Projection equivalence is assessed by its objective, fidelity and stability, not +pixel equality. Alternative/approximate algorithms remain explicitly selectable; +they do not silently replace the baseline. If this gate is infeasible, report it +as unfinished rather than declaring an inference-only viewer complete. + +### 7. Standalone distribution and release audit + +Package as a static application with an installable/offline route. Static hosting +may serve files, but must perform no inference or numerical analysis. Do not promise +that double-clicking `file://` supports every browser capability. Bundle runtime +assets; record required secure-context/isolation settings and tested browsers. + +Use local model/image selection and verify offline reopening after installation. +Treat GBIF images as optional network content with attribution and explicit offline +behaviour. Test model replacement, memory/resource disposal and failure recovery. + +**Gate:** operate from a clean environment without Python/PyTorch/CUDA, with model +and images retained locally. Network inspection proves no inference-service calls. +All existing explorer interactions still work, including labels/aspect ratios, +arrival continuity, culling fades and fixed prototype positions. + +## Validation and completion evidence + +Extend `tests/export/test_onnx.py`, the affected `tests/utils/test_prototype_*` +coverage, and the existing browser checks. Keep new fixtures small for regression +runs; maintain a separate reproducible real-checkpoint qualification command. + +Run `bash dev/check.sh static`, affected tests, and `bash dev/check.sh all` for the +export/loading changes as required by repository policy. Run +`bash dev/check-wheel.sh` for packaging/extras, plus installed-bundle browser tests. +Use isolated dependency environments with an explicit PyTorch backend; never sync +the shared CUDA environment implicitly. Preserve real fixtures and outputs outside +Git; commit schemas, tests, commands and compact evidence summaries. + +Completion requires all seven gates, a runnable distribution, updated usage docs, +and an evidence table recording artifact hashes, platforms, numerical errors, +latency/memory, skips and unresolved limitations. A miniature model, CPU ONNX check, +precomputed-only viewer or screenshot alone is insufficient. Commit bounded stable +increments and update the compact roadmap; do not mark the overall goal complete +when only an earlier milestone has passed. diff --git a/docs/prototype-explorer-roadmap.md b/docs/prototype-explorer-roadmap.md index 1c94d5ef..cbc298d1 100644 --- a/docs/prototype-explorer-roadmap.md +++ b/docs/prototype-explorer-roadmap.md @@ -32,6 +32,9 @@ longitudinal comparisons. Browser inference parity is still an open gate. ## Standalone client direction +The [goal-ready implementation plan](prototype-browser-implementation.md) defines +the bundle contract, ordered milestones, and end-to-end completion gates. + Target: browser inference and embedding inspection without a Python/PyTorch/CUDA runtime. Export preparation may use mini_trainer once; the distributed viewer loads an ONNX bundle, class/prototype metadata, and executable preprocessing. From 35c4d5e90a867b40837db7d4e094c45b01245920 Mon Sep 17 00:00:00 2001 From: asgersvenning Date: Fri, 11 Sep 2026 10:52:54 +0200 Subject: [PATCH 21/34] feat: add responsive focused explorer views --- dev/prototype_space/check_browser.mjs | 32 +++++++++++ docs/prototype-explorer-roadmap.md | 6 ++- docs/prototype-explorer.md | 14 +++++ .../prototype_space/projection.js | 33 ++++++++---- .../visualization/prototype_space/report.html | 54 ++++++++++++++++--- .../prototype_space/thumbnails.js | 7 +-- 6 files changed, 123 insertions(+), 23 deletions(-) diff --git a/dev/prototype_space/check_browser.mjs b/dev/prototype_space/check_browser.mjs index a5f68596..99750607 100644 --- a/dev/prototype_space/check_browser.mjs +++ b/dev/prototype_space/check_browser.mjs @@ -208,5 +208,37 @@ try{ if(photos.exceptionDetails)throw Error(JSON.stringify(photos.exceptionDetails)); report.checks.push(...photos.result.value);report.javascript_errors=errors.length; writeFileSync(join(output,'browser-check.json'),JSON.stringify(report,null,2)+'\n');console.log(JSON.stringify({photo_checks:photos.result.value,javascript_errors:errors.length})); + const focusChecks=[]; + for(const [width,height] of [[1800,900],[900,1500],[390,844]]){ + await call('Emulation.setDeviceMetricsOverride',{width,height,deviceScaleFactor:1,mobile:false}); + const focused=await call('Runtime.evaluate',{expression:`(async()=>{ + const checks=[],check=(v,label)=>{if(!v)throw Error(label);checks.push(label)}; + const before={selected,treeFocus,plane:$('projection-plane').value,x:projectionView.x,y:projectionView.y}; + setWorkspaceView('projection');await new Promise(r=>setTimeout(r,250)); + const rect=projectionCanvas.getBoundingClientRect(); + check(! $('inspector').hidden&&!$('photo-panel').hidden&&$('tree-panel').hidden,'projection groups related panels'); + check(rect.width>innerWidth*.8&&rect.height>=300,'projection uses viewport width and height'); + check(Math.abs(rect.width/1200-rect.height/projectionHeight)<1e-8,'projection preserves equal screen scale'); + check(selected===before.selected&&treeFocus===before.treeFocus&&$('projection-plane').value===before.plane,'focus preserves selection tree and plane'); + check(projectionView.x===before.x&&projectionView.y===before.y,'focus preserves projection center'); + const origin=projectionPoint([projectionView.x,projectionView.y]); + const hit=projectionMouse({clientX:rect.left+origin[0]*rect.width/1200,clientY:rect.top+origin[1]*rect.height/projectionHeight}); + check(Math.hypot(hit[0]-origin[0],hit[1]-origin[1])<1e-8,'pointer conversion follows focused aspect ratio'); + setWorkspaceView('tree');await new Promise(r=>setTimeout(r,100)); + check(!$('tree-panel').hidden&&!$('inspector').hidden&&$('projection-panel').hidden,'tree groups selected-class context'); + check($('tree').viewBox.baseVal.width>=760,'tree preserves readable labels'); + check(document.documentElement.scrollWidth<=innerWidth+1,'focused layout avoids page-wide horizontal scrolling'); + setWorkspaceView('matrix');check(!$('matrix-panel').hidden&&!$('inspector').hidden&&$('tree-panel').hidden,'matrix groups local inspection'); + setWorkspaceView('all');await new Promise(r=>setTimeout(r,100)); + check([...document.querySelectorAll('main .panel')].every(p=>!p.hidden),'all views restored'); + return checks; + })()`,returnByValue:true,awaitPromise:true}); + if(focused.exceptionDetails)throw Error(JSON.stringify(focused.exceptionDetails)); + focusChecks.push({width,height,checks:focused.result.value}); + } + if(errors.length)throw Error(JSON.stringify(errors)); + report.focus_checks=focusChecks;report.javascript_errors=errors.length; + writeFileSync(join(output,'browser-check.json'),JSON.stringify(report,null,2)+'\n'); + console.log(JSON.stringify({focusChecks,javascript_errors:errors.length})); await send('Browser.close'); }catch(e){console.error(e);process.exitCode=1;chrome.kill('SIGTERM');}finally{clearTimeout(deadline);setTimeout(()=>rmSync(profile,{recursive:true,force:true}),1000);} diff --git a/docs/prototype-explorer-roadmap.md b/docs/prototype-explorer-roadmap.md index cbc298d1..d249466c 100644 --- a/docs/prototype-explorer-roadmap.md +++ b/docs/prototype-explorer-roadmap.md @@ -7,13 +7,15 @@ proposals, not active implementation commitments. ## Delivered -On `feature/prototype-space`, through `4763041` (not yet integrated into the -main development branch): +Implemented on `feature/prototype-space` (not yet integrated into the main +development branch; reusable-package baseline `4763041`): - Reusable package, `mt_explore`, browser weight selection, and portable export. - Baseline distance matrices and Ward tree; direct log-tail diagnostics and configurable display clipping; linked class and neighbourhood inspection. - PCA and angular t-SNE, with measured neighbourhood retention. +- Focused feature layouts with related panels, responsive projection aspect ratio, + and retained class/plane/subtree state; verified at landscape, portrait and mobile sizes. - GBIF photo labels, configurable footprints/culling, continuous arrival motion, pan/zoom retention, and quick navigation exit fades. diff --git a/docs/prototype-explorer.md b/docs/prototype-explorer.md index d508b861..6f83a013 100644 --- a/docs/prototype-explorer.md +++ b/docs/prototype-explorer.md @@ -53,6 +53,20 @@ The equivalent module command works without an installed console entry point: python -m mini_trainer.visualization.prototype_space path/to/weights.pt ``` +## Focus a view + +Use **Focus view** on a feature panel, or the global **View** selector, to open +spatial projection, dendrogram, distance matrices, class images, or local +neighbourhood inspection. Each focus keeps related panels together: for example, +projection includes the neighbourhood profile, local inspector, and class photos. +Choose **All views** to return to the overview. + +Focused layouts use the available screen width. The spatial map adapts its aspect +ratio to viewport height while preserving equal geometric scale on both axes. +Dendrograms use the wider layout with readable labels and internal scrolling on +narrow displays. Class selection, projection centre/plane, and the current tree +subtree survive view changes. Related panels wrap below the main feature. + ## Export without serving ```bash diff --git a/mini_trainer/visualization/prototype_space/projection.js b/mini_trainer/visualization/prototype_space/projection.js index d4b1f383..ce6056fe 100644 --- a/mini_trainer/visualization/prototype_space/projection.js +++ b/mini_trainer/visualization/prototype_space/projection.js @@ -4,17 +4,18 @@ let projectionView = {x:0, y:0, scale:1}; let projectionDrag = null; let projectionHits = []; const projectionCanvas = $('projection-map'); +let projectionHeight=600; function currentProjection() { return data.projections?.[$('projection-plane').value]; } -function projectionPoint(point) {return [600+(point[0]-projectionView.x)*projectionView.scale,300-(point[1]-projectionView.y)*projectionView.scale];} +function projectionPoint(point) {return [600+(point[0]-projectionView.x)*projectionView.scale,projectionHeight/2-(point[1]-projectionView.y)*projectionView.scale];} function fitProjection(ids) { const p=currentProjection();if(!p)return; const points=ids.map(i=>p.coordinates[i]); let x0=Infinity,x1=-Infinity,y0=Infinity,y1=-Infinity; for(const [x,y] of points){x0=Math.min(x0,x);x1=Math.max(x1,x);y0=Math.min(y0,y);y1=Math.max(y1,y);} - projectionView={x:(x0+x1)/2,y:(y0+y1)/2,scale:Math.min(1050/Math.max(.0001,x1-x0),450/Math.max(.0001,y1-y0))}; + projectionView={x:(x0+x1)/2,y:(y0+y1)/2,scale:Math.min(1050/Math.max(.0001,x1-x0),Math.max(100,projectionHeight-150)/Math.max(.0001,y1-y0))}; } function drawProjection() { - const ctx=projectionCanvas.getContext('2d');ctx.clearRect(0,0,1200,600); + const ctx=projectionCanvas.getContext('2d');ctx.clearRect(0,0,1200,projectionHeight); if(projectionCase!==data){ projectionCase=data;$('projection-plane').replaceChildren(); for(const name of Object.keys(data.projections||{})){const option=document.createElement('option');option.value=option.textContent=name;$('projection-plane').append(option);} @@ -27,15 +28,15 @@ function drawProjection() { ctx.lineWidth=1;ctx.strokeStyle='#dce3e9';ctx.fillStyle='#52687b';ctx.font='12px system-ui'; // Both axes use the same units per pixel, including after pan/zoom. for(let i=0;i<=4;i++){ - const x=60+i*270,y=30+i*135; - ctx.beginPath();ctx.moveTo(x,20);ctx.lineTo(x,570);ctx.stroke(); - ctx.fillText((projectionView.x+(x-600)/projectionView.scale).toPrecision(3),x+3,590); + const x=60+i*270,y=30+i*(projectionHeight-60)/4; + ctx.beginPath();ctx.moveTo(x,20);ctx.lineTo(x,projectionHeight-30);ctx.stroke(); + ctx.fillText((projectionView.x+(x-600)/projectionView.scale).toPrecision(3),x+3,projectionHeight-10); ctx.beginPath();ctx.moveTo(35,y);ctx.lineTo(1180,y);ctx.stroke(); - ctx.fillText((projectionView.y-(y-300)/projectionView.scale).toPrecision(3),2,y+13); + ctx.fillText((projectionView.y-(y-projectionHeight/2)/projectionView.scale).toPrecision(3),2,y+13); } ctx.fillText(p.axes[0]+' →',1120,15);ctx.fillText(p.axes[1],5,15); for(let i=0;i1200||y<0||y>600)continue; + const [x,y]=projectionHits[i];if(x<0||x>1200||y<0||y>projectionHeight)continue; ctx.fillStyle='#7c90a34d';ctx.beginPath();ctx.arc(x,y,2,0,2*Math.PI);ctx.fill(); } const [sx,sy]=projectionHits[selected]; @@ -49,7 +50,7 @@ function drawProjection() { $('projection-quality').textContent=`${summary}Original top-${data.stats.k} neighbours retained among plane top-${data.stats.k}: ${(100*p.mean_retained_fraction).toFixed(1)}% averaged over classes; ${(100*p.retained_fraction[selected]).toFixed(1)}% for selected class. Exact plane-distance ties use class order.`; if(typeof scheduleMapThumbnails==='function')scheduleMapThumbnails(); } -function projectionMouse(event){const r=projectionCanvas.getBoundingClientRect();return [(event.clientX-r.left)*1200/r.width,(event.clientY-r.top)*600/r.height];} +function projectionMouse(event){const r=projectionCanvas.getBoundingClientRect();return [(event.clientX-r.left)*1200/r.width,(event.clientY-r.top)*projectionHeight/r.height];} function projectionHit(point){if(typeof mapThumbnailHit==='function'){const hit=mapThumbnailHit(point);if(hit>=0)return hit;}let best=-1,distance=100;projectionHits.forEach(([x,y],i)=>{const d=(x-point[0])**2+(y-point[1])**2;if(d{if(e.button!==0)return;projectionDrag={point:projectionMouse(e),view:{...projectionView},moved:false};projectionCanvas.setPointerCapture(e.pointerId);}; projectionCanvas.onpointermove=e=>{ @@ -64,10 +65,22 @@ projectionCanvas.onpointercancel=()=>{projectionDrag=null;}; projectionCanvas.addEventListener('wheel',e=>{ e.preventDefault();const [x,y]=projectionMouse(e),old=projectionView.scale; const scale=Math.max(1,Math.min(1e7,old*Math.exp(-Math.max(-300,Math.min(300,e.deltaY))*.002))); - projectionView.x+=(x-600)*(1/old-1/scale);projectionView.y-=(y-300)*(1/old-1/scale);projectionView.scale=scale;drawProjection(); + projectionView.x+=(x-600)*(1/old-1/scale);projectionView.y-=(y-projectionHeight/2)*(1/old-1/scale);projectionView.scale=scale;drawProjection(); },{passive:false}); $('projection-plane').onchange=()=>{fitProjection(data.names.map((_,i)=>i));drawProjection();}; $('projection-reset').onclick=()=>{fitProjection(data.names.map((_,i)=>i));drawProjection();}; $('projection-focus').onclick=()=>{fitProjection([selected,...data.neighbours[selected]]);drawProjection();}; $('projection-edges').onchange=drawProjection; drawProjection(); + +let projectionScreenWidth=0; +new ResizeObserver(()=>{ + const rect=projectionCanvas.getBoundingClientRect();if(!rect.width||!rect.height)return; + const nextHeight=1200*rect.height/rect.width; + if(Math.abs(nextHeight-projectionHeight)<.01&&Math.abs(rect.width-projectionScreenWidth)<.1)return; + if(projectionScreenWidth)projectionView.scale*=projectionScreenWidth/rect.width; + projectionScreenWidth=rect.width;projectionHeight=nextHeight; + projectionCanvas.height=Math.round(nextHeight); + projectionCanvas.getContext('2d').setTransform(1,0,0,projectionCanvas.height/projectionHeight,0,0); + drawProjection(); +}).observe(projectionCanvas); diff --git a/mini_trainer/visualization/prototype_space/report.html b/mini_trainer/visualization/prototype_space/report.html index 2a1b9c55..bf70a2e7 100644 --- a/mini_trainer/visualization/prototype_space/report.html +++ b/mini_trainer/visualization/prototype_space/report.html @@ -19,11 +19,24 @@ @keyframes thumb-appear{from{opacity:0}to{opacity:1}} .map-thumb{animation:thumb-appear .18s ease-out;position:absolute;transform:translate(-50%,-50%);padding:2px;border:2px solid #087e8b;border-radius:4px;background:white;pointer-events:none;overflow:hidden;box-shadow:0 1px 4px #0003}.map-thumb img{display:block;width:100%;object-fit:contain;background:#f5f7fa}.map-thumb span{display:block;font-size:10px;height:16px;line-height:16px;white-space:nowrap;overflow:hidden}.map-thumb.image-only{padding:0;border:0;border-radius:0;box-shadow:none}.map-thumb.image-only img{object-fit:cover;background:transparent}.map-thumb.original-aspect img{object-fit:contain}.map-thumb.image-only:focus-visible{outline-offset:-3px}.hide-thumb-anchors .map-thumb-tether{display:none}.map-thumb-tether{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;overflow:visible}.map-thumb:focus-visible{outline:3px solid #ffae3d} @media(prefers-reduced-motion:reduce){.map-thumb{animation:none}} + +[hidden]{display:none!important} +body.focused header,body.focused #stats,body.focused #case-note,body.focused footer{display:none} +body.focused main{max-width:none;padding:16px;display:flex;flex-wrap:wrap;gap:16px} +body.focused #overview-charts{display:contents} +body.focused main .panel{min-width:0;margin:0;flex:1 1 480px;max-width:100%} +body.focused main .primary-view{order:-1;flex-basis:100%} +body.focused #projection-map{aspect-ratio:auto!important;height:max(300px,calc(100dvh - 340px));border:0!important} +body.focused .tree-wrap{max-height:calc(100dvh - 210px);min-height:300px;overflow:auto} +body.focused #matrix-panel .matrix{max-width:min(100%,calc(100dvh - 350px));margin:auto} +.panel-focus{font-size:12px;float:right;margin:0 0 8px 12px} +@media(max-width:850px){body.focused main{padding:8px}.controls{gap:8px;padding:8px}.grid>div{min-width:0;overflow:auto}body.focused .panel{padding:14px}}
mini_trainer · empirical exploration

Prototype observatory

Move between the global tree, pairwise structure and a class’s local neighbourhood. The existing mini_trainer distance is the reference; the pre-CDF z-score exposes detail that can be hidden by probability rounding.

Save HTML report + @@ -31,9 +44,9 @@

-
-

Distribution of pairwise z-scores

The actual transform used by mini_trainer, before the Normal CDF. Self-pairs excluded. No fitted distribution or assumed organization.

-

Neighbourhood signature

Similarity to the 1st, 5th, 12th and 32nd neighbour in the full space. High values across ranks indicate several strongly aligned directions.

Teal: selected class. Grey: per-rank median across classes. Rank points are not an embedding projection.

+
+

Distribution of pairwise z-scores

The actual transform used by mini_trainer, before the Normal CDF. Self-pairs excluded. No fitted distribution or assumed organization.

+

Neighbourhood signature

Similarity to the 1st, 5th, 12th and 32nd neighbour in the full space. High values across ranks indicate several strongly aligned directions.

Teal: selected class. Grey: per-rank median across classes. Rank points are not an embedding projection.

Prototype space · spatial projection

Compare linear PCA with angular t-SNE when available. Scroll to zoom, drag to pan, click a point or thumbnail to inspect its class. Each case is fitted separately.

@@ -42,12 +55,12 @@

Hover a point for its class ID.

Red: selected class · teal: its neighbours in the original space · orange rings: nearby in this plane only · grey: other classes. Links follow the original-space ranking. Projected gaps and apparent overlaps can differ from the full embedding geometry.

Browse by class image

Photo labels for the selected class and its nearest prototype directions. The prototype marks the learned target direction for the class; a GBIF class example provides its visual label. Credits and exact class IDs stay attached.

-
+

Explore the existing dendrogram

Same Ward linkage on class_distance, shown as a rectangular tree with at most 32 visible tips. Click a collapsed tip to expand its subtree; click a class to inspect it. Heights retain the existing linkage units.

-
+

What survives matrix aggregation?

Both panels represent the current heatmap quantity, 1 − CDF(z), in the same dendrogram leaf order. The log-domain option evaluates log Φ(−z) directly in float32. Legacy mode exposes CDF saturation; the two panels compare block summaries. Self-pairs and padding are excluded from both.

Clipping affects colours only, not numerical values or neighbour rankings.

Maximum dissimilarity per block

Uses the current renderer’s maximum-summary idea. A distant pair can dominate a block.

Closest pair per block

An explicit alternative. Reveals isolated close pairs; does not imply the entire block is close.

@@ -98,9 +111,12 @@ const svg=$('tree'),n=data.names.length;svg.replaceChildren();let frontier=[treeFocus]; while(frontier.length<32){let at=-1;frontier.forEach((node,i)=>{if(node>=n&&(at<0||height(node)>height(frontier[at])))at=i;});if(at<0)break;frontier.splice(at,1,...children(frontier[at]));} frontier.sort((a,b)=>starts[a]-starts[b]);const tips=new Set(frontier),ys=new Map(frontier.map((node,i)=>[node,30+i*19])); - const totalHeight=Math.max(100,frontier.length*19+50);svg.setAttribute('viewBox',`0 0 1200 ${totalHeight}`); - const max=height(treeFocus),x=node=>max>0?24+790*(1-height(node)/max):24+790*(1-size(node)/Math.max(1,size(treeFocus))); - function draw(node){if(!tips.has(node)){const [a,b]=children(node);draw(a);draw(b);ys.set(node,(ys.get(a)+ys.get(b))/2);for(const child of [a,b])add(svg,'path',{d:`M ${x(node)} ${ys.get(node)} V ${ys.get(child)} H ${x(child)}`,fill:'none',stroke:contains(child,selected)?'#008e9b':'#b3c1cd','stroke-width':contains(child,selected)?2:1});}else{const y=ys.get(node),label=node{if(node{if(e.key==='Enter')action();};if(contains(node,selected))add(svg,'circle',{cx:x(node),cy:y,r:4,fill:'#087e8b'});}} + const focused=document.body.dataset.view==='tree',treeWidth=focused?Math.max(760,svg.parentElement.clientWidth):1200; + const totalHeight=Math.max(100,frontier.length*19+50,focused?innerHeight-260:0); + if(focused)frontier.forEach((node,i)=>ys.set(node,30+i*(totalHeight-60)/Math.max(1,frontier.length-1))); + const branchWidth=treeWidth-410;svg.setAttribute('viewBox',`0 0 ${treeWidth} ${totalHeight}`); + const max=height(treeFocus),x=node=>max>0?24+branchWidth*(1-height(node)/max):24+branchWidth*(1-size(node)/Math.max(1,size(treeFocus))); + function draw(node){if(!tips.has(node)){const [a,b]=children(node);draw(a);draw(b);ys.set(node,(ys.get(a)+ys.get(b))/2);for(const child of [a,b])add(svg,'path',{d:`M ${x(node)} ${ys.get(node)} V ${ys.get(child)} H ${x(child)}`,fill:'none',stroke:contains(child,selected)?'#008e9b':'#b3c1cd','stroke-width':contains(child,selected)?2:1});}else{const y=ys.get(node),label=node{if(node{if(e.key==='Enter')action();};if(contains(node,selected))add(svg,'circle',{cx:x(node),cy:y,r:4,fill:'#087e8b'});}} draw(treeFocus);$('tree-note').textContent=`Showing ${size(treeFocus).toLocaleString()} classes · root height ${fmt(max)} · ${frontier.length} visible tips${max===0?' · all heights zero: horizontal positions use class counts for display':''}`; } function setRangePreset(){const mode=$('range-preset').value;let bounds;if(mode==='focus')bounds=[-8,0];else if(mode==='robust')bounds=data.robust_log10_range;else if(mode==='full')bounds=[data.matrix_log10_floor,0];if(bounds){$('range-low').value=bounds[0].toFixed(3);$('range-high').value=bounds[1].toFixed(3);}drawGlobal();} @@ -141,3 +157,25 @@ + + diff --git a/mini_trainer/visualization/prototype_space/thumbnails.js b/mini_trainer/visualization/prototype_space/thumbnails.js index d3e8b214..fca6e408 100644 --- a/mini_trainer/visualization/prototype_space/thumbnails.js +++ b/mini_trainer/visualization/prototype_space/thumbnails.js @@ -192,7 +192,7 @@ function thumbnailTether(box,always=false){ svg.append(line,dot);return svg; } function mapThumbnailHit(point){ - const rect=projectionCanvas.getBoundingClientRect(),x=point[0]*rect.width/1200,y=point[1]*rect.height/600; + const rect=projectionCanvas.getBoundingClientRect(),x=point[0]*rect.width/1200,y=point[1]*rect.height/projectionHeight; const box=mapThumbLayout.find(b=>x>=b.left&&x<=b.left+b.width&&y>=b.top&&y<=b.top+b.height); return box?.id??-1; } @@ -234,11 +234,12 @@ function setThumbnailAnchors(){ } function scheduleMapThumbnails(){ clearTimeout(mapThumbTimer);stopThumbnailMotion();mapThumbController?.abort();++mapThumbGeneration; + if(!projectionCanvas.getBoundingClientRect().width)return; const size=Number($('map-photo-size').value),density=Number($('map-photo-density').value),overlap=Number($('map-photo-overlap').value); const key=[size,$('map-photo-labels').checked,$('map-photo-push').checked,$('map-photo-aspect').checked,$('projection-plane').value].join(':'); if($('map-photos').checked&&mapThumbCase===data&&mapThumbKey===key){ const rect=projectionCanvas.getBoundingClientRect(); - reanchorThumbnails(projectionHits.map(([x,y])=>[x*rect.width/1200,y*rect.height/600]),rect.width,rect.height); + reanchorThumbnails(projectionHits.map(([x,y])=>[x*rect.width/1200,y*rect.height/projectionHeight]),rect.width,rect.height); mapThumbOffsets=new Map(mapThumbLayout.map(b=>[b.id,[b.x-b.anchorX,b.y-b.anchorY]])); }else{mapThumbOffsets.clear();mapThumbLayout=[];$('map-thumbnail-layer').replaceChildren();} mapThumbCase=data;mapThumbKey=key; @@ -254,7 +255,7 @@ async function renderMapThumbnails(){ const rect=projectionCanvas.getBoundingClientRect(),size=Number($('map-photo-size').value); const config={width:rect.width,height:rect.height,size,density:Number($('map-photo-density').value),overlap:Number($('map-photo-overlap').value)/100,labels:$('map-photo-labels').checked,push:$('map-photo-push').checked,aspect:$('map-photo-aspect').checked}; if(config.aspect)config.aspects=data.names.map(id=>{const album=photoAlbums.get(id),photo=album?.photos[(photoChoices.get(id)||0)%(album?.photos.length||1)],image=photo&&mapThumbImages.get(photo.image_path);return image?.naturalWidth/image?.naturalHeight;}); - const points=projectionHits.map(([x,y])=>[x*rect.width/1200,y*rect.height/600]); + const points=projectionHits.map(([x,y])=>[x*rect.width/1200,y*rect.height/projectionHeight]); // Selected class first, then its true neighbours; other classes have stable // mixed row order so dense regions do not win simply by checkpoint ordering. const remaining=data.names.map((_,i)=>i).sort((a,b)=>(Math.imul(a,2654435761)>>>0)-(Math.imul(b,2654435761)>>>0)); From 8ef173f4c2bd7f1cc9d716344ba5df3601ade302 Mon Sep 17 00:00:00 2001 From: asgersvenning Date: Fri, 11 Sep 2026 11:25:02 +0200 Subject: [PATCH 22/34] feat: add prototype names and chance-alignment views --- dev/prototype_space/check_browser.mjs | 35 ++++ dev/prototype_space/check_layout.mjs | 9 ++ docs/prototype-explorer-roadmap.md | 6 + docs/prototype-explorer.md | 32 +++- .../visualization/prototype_space/explore.py | 7 +- .../prototype_space/projection.js | 31 ++-- .../visualization/prototype_space/report.html | 149 +++++++++++++++--- .../visualization/prototype_space/serve.py | 10 ++ tests/utils/test_prototype_exploration.py | 5 + tests/utils/test_prototype_photos.py | 16 ++ 10 files changed, 266 insertions(+), 34 deletions(-) diff --git a/dev/prototype_space/check_browser.mjs b/dev/prototype_space/check_browser.mjs index 99750607..50993b9a 100644 --- a/dev/prototype_space/check_browser.mjs +++ b/dev/prototype_space/check_browser.mjs @@ -240,5 +240,40 @@ try{ report.focus_checks=focusChecks;report.javascript_errors=errors.length; writeFileSync(join(output,'browser-check.json'),JSON.stringify(report,null,2)+'\n'); console.log(JSON.stringify({focusChecks,javascript_errors:errors.length})); + await call('Emulation.setDeviceMetricsOverride',{width:1440,height:1100,deviceScaleFactor:2,mobile:false}); + const enhanced=await call('Runtime.evaluate',{expression:`(async()=>{ + const checks=[],check=(v,label)=>{if(!v)throw Error(label);checks.push(label)}; + setWorkspaceView('projection');await new Promise(r=>setTimeout(r,200)); + const before={selected,plane:$('projection-plane').value,x:projectionView.x,y:projectionView.y,names:JSON.stringify(data.names)}; + $('class-names').checked=true; + const local=new Map([[selected,'Selected species'],[data.neighbours[selected][0],'Neighbour species']]);importedNames.set(data,local);refreshClassLabels(); + check($('selected-name').textContent.includes('Selected species'),'selected class shows optional name and ID'); + check($('neighbours').textContent.includes('Neighbour species'),'neighbour table resolves names'); + check($('local').textContent.includes('Neighbour species'),'matrix axes and hover resolve names'); + check($('profile').textContent.includes('Neighbour species'),'profile retains rank with neighbour name'); + $('search').value='Neighbour species';$('find').click();check(selected===data.neighbours[before.selected][0],'manual selector searches resolved names');selectClass(before.selected); + treeFocus=selected;drawTree();check($('tree').textContent.includes('Selected species'),'dendrogram leaves resolve names'); + check(JSON.stringify(data.names)===before.names,'names never replace checkpoint IDs'); + $('score-display').value='tail';$('score-display').onchange(); + check($('score-column').textContent.includes('Φ')&&$('metric').value==='tail','probability toggle updates table and matrix'); + check(!$('score-note').hidden&&$('local-scale').textContent.includes('Upper-tail'),'probability reference and logarithmic colours disclosed'); + check($('profile').textContent.includes('Upper-tail'),'profile probability axis'); + $('score-display').value='z';$('score-display').onchange();check($('score-column').textContent==='z','z display restores'); + const original=window.fetch,ids=[selected,...data.neighbours[selected]],pending=[];let inFlight=0,peak=0; + importedNames.delete(data);resolvedNames.clear();nameFailures.clear(); + window.fetch=async url=>{if(String(url).startsWith('/api/taxon/')){inFlight++;peak=Math.max(peak,inFlight);pending.push(url);await new Promise(r=>setTimeout(r,30));inFlight--;return {ok:true,json:async()=>({class_id:String(url).split('/').pop(),display_name:'Async taxon '+String(url).split('/').pop()})};}return original(url);}; + $('gbif-names').checked=true;requestClassNames(); + for(let i=0;i<60&&!resolvedNames.size;i++)await new Promise(r=>setTimeout(r,30)); + await new Promise(r=>setTimeout(r,200)); + check(resolvedNames.size>0&&pending.every(url=>url.startsWith('/api/taxon/')),'asynchronous names need no photo lookup'); + check(peak===1&&pending.length<30,'name lookup is bounded and prioritizes a small visible set'); + $('class-names').checked=false;$('gbif-names').checked=false;await new Promise(r=>setTimeout(r,100));window.fetch=original;refreshClassLabels(); + check($('selected-name').textContent===data.names[selected],'IDs restore without losing selected class'); + const rect=projectionCanvas.getBoundingClientRect();check(projectionCanvas.width===Math.round(rect.width*devicePixelRatio)&&projectionCanvas.height===Math.round(rect.height*devicePixelRatio),'canvas backing matches physical screen pixels'); + check(rect.height>=300,'focused map retains vertical workspace'); + return checks; + })()`,returnByValue:true,awaitPromise:true}); + if(enhanced.exceptionDetails)throw Error(JSON.stringify(enhanced.exceptionDetails)); + console.log(JSON.stringify({enhancedChecks:enhanced.result.value,javascript_errors:errors.length})); await send('Browser.close'); }catch(e){console.error(e);process.exitCode=1;chrome.kill('SIGTERM');}finally{clearTimeout(deadline);setTimeout(()=>rmSync(profile,{recursive:true,force:true}),1000);} diff --git a/dev/prototype_space/check_layout.mjs b/dev/prototype_space/check_layout.mjs index 9bfceb20..b91069cd 100644 --- a/dev/prototype_space/check_layout.mjs +++ b/dev/prototype_space/check_layout.mjs @@ -97,3 +97,12 @@ for(let i=0;i<600&&coordinator.motion();i++)frame(); assert.equal(coordinator.motion(),null,'joined animation eventually settles'); await running; console.log(JSON.stringify({arrivalContinuityChecks:12})); + +const template=readFileSync(new URL('./report.html',import.meta.url),'utf8'); +const {referenceLogTail,probabilityText}=Function(template.slice(template.indexOf('function referenceLogTail'),template.indexOf('function scoreText'))+'return {referenceLogTail,probabilityText};')(); +const probabilityReference=[[-56.0, -0.0], [-12.0, -1.776482112077648e-33], [-8.0, -6.220960574271758e-16], [-1.0, -0.17275377902344985], [0.0, -0.6931471805599453], [1.0, -1.841021645009264], [8.0, -35.01343715991456], [12.0, -75.41067300156881], [31.0, -484.8539636271794], [56.0, -1572.9446088476825]]; +for(const [z,logTail] of probabilityReference)assert.ok(Math.abs(referenceLogTail(z)-logTail)<3e-7,'direct browser log tail agrees with independent torch float64 reference'); +for(let z=-56;z<56;z+=.1)assert.ok(referenceLogTail(z+.1)<=referenceLogTail(z),'chance-alignment tail decreases with alignment'); +assert.notEqual(probabilityText(referenceLogTail(56)/Math.LN10),'0','extreme probability remains readable without underflow'); +assert.ok(probabilityText(Math.log10(.999999)).startsWith('1 −'),'near-one values show their complement'); +console.log(JSON.stringify({logTailReferenceChecks:probabilityReference.length})); diff --git a/docs/prototype-explorer-roadmap.md b/docs/prototype-explorer-roadmap.md index d249466c..9880d4dc 100644 --- a/docs/prototype-explorer-roadmap.md +++ b/docs/prototype-explorer-roadmap.md @@ -18,6 +18,9 @@ development branch; reusable-package baseline `4763041`): and retained class/plane/subtree state; verified at landscape, portrait and mobile sizes. - GBIF photo labels, configurable footprints/culling, continuous arrival motion, pan/zoom retention, and quick navigation exit fades. +- Optional asynchronous taxon names with visible-label priority and offline aliases; + z/chance-alignment probability views with explicit reference semantics. +- Display-density projection rendering and viewport-height focused panels. Evidence: real checkpoint diagnostics match the preserved baseline; 84 browser checks, 12 affected Python tests, motion checks, static checks, and installed-wheel @@ -25,6 +28,9 @@ CLI/file-picker checks passed. Current extraction supports float32 linear `Classifier` and `HierarchicalClassifier` heads; dense analysis needs several GB for the 12,632-class case. These checks do not establish full-suite/GPU coverage. +The names/probability/layout increment additionally passed 133 browser assertions, +11 affected Python tests, log-tail reference and motion checks, and static checks. + Current real-data reference: `tmp/best_global-lepi-production-w32-1_epoch26.pt` (training progress reported as 26/30 epochs), SHA-256 `abb9f66d95fe867bd31847cb33782a353b1d3faf4c79c4b10d93037f33fe61f4`. diff --git a/docs/prototype-explorer.md b/docs/prototype-explorer.md index 6f83a013..68b4131e 100644 --- a/docs/prototype-explorer.md +++ b/docs/prototype-explorer.md @@ -61,12 +61,40 @@ neighbourhood inspection. Each focus keeps related panels together: for example, projection includes the neighbourhood profile, local inspector, and class photos. Choose **All views** to return to the overview. -Focused layouts use the available screen width. The spatial map adapts its aspect -ratio to viewport height while preserving equal geometric scale on both axes. +Focused projection and dendrogram panels use the available viewport height as +well as width. Thumbnail settings collapse to leave more space for the map. +The spatial map adapts its aspect ratio while preserving equal geometric scale +on both axes; its raster follows display pixel density with readable screen-size labels. Dendrograms use the wider layout with readable labels and internal scrolling on narrow displays. Class selection, projection centre/plane, and the current tree subtree survive view changes. Related panels wrap below the main feature. +## Class names and chance alignment + +Open **Class names** to enable aliases in selection, neighbourhood profiles, +dendrogram leaves, local matrix axes, and neighbour tables. Explicitly enable +GBIF lookup when the class IDs are GBIF taxon keys. Names resolve asynchronously, +prioritizing visible labels and the selected neighbourhood; unresolved classes +retain their IDs. Online resolution requires the local server and GBIF access. +Search accepts IDs, already resolved names, or `row N` for a checkpoint row. + +For offline aliases, import JSON such as `{"1837646": "Taxon name"}`, or +`{"by_id": {"1837646": "Taxon name"}, "by_index": {"0": "Another name"}}`. +Aliases affect display only and never change checkpoint class ordering or IDs. + +The score selector switches between z-scores and **Chance-alignment p-value +(approx.)**, `q = Φ(−z)`. This is the approximate one-sided tail under independent +uniform directions on the unit hypersphere: small q means random directions +would rarely be this closely aligned. It is not a posterior probability of +similarity or a calibrated test of whether two learned classes differ. +The reference does not assume that the learned prototypes themselves are uniform. + +Probability formatting uses a direct log-tail approximation; the local matrix +retains the repository's stored log-tail values. Logarithmic colour limits remain +configurable, and tooltips retain unclipped values. Histogram bins retain their +original z positions, while the neighbourhood probability profile uses a log +axis. Ward linkage heights keep their original units. + ## Export without serving ```bash diff --git a/mini_trainer/visualization/prototype_space/explore.py b/mini_trainer/visualization/prototype_space/explore.py index d6f0f6bf..0f0739db 100644 --- a/mini_trainer/visualization/prototype_space/explore.py +++ b/mini_trainer/visualization/prototype_space/explore.py @@ -135,11 +135,15 @@ def analyze(weight, names, groups, *, neighbours=12, seed=42, metadata=None, inc k = min(neighbours, n - 1) # z is a separately labelled refinement of saturated distance ties. # Stable sorting uses checkpoint class order for exactly equal z values. + ranks = [1, min(5, n - 1), min(12, n - 1), min(32, n - 1)] + profile_neighbours = np.empty((n, len(ranks)), dtype=np.int32) near = np.empty((n, k), dtype=np.int32) for i in range(n): row = z[i].copy() row[i] = -np.inf - near[i] = np.argsort(-row, kind="stable")[:k] + ordered = np.argsort(-row, kind="stable") + near[i] = ordered[:k] + profile_neighbours[i] = ordered[np.asarray(ranks) - 1] ids = np.column_stack((np.arange(n), near)) local_d = distance[ids[:, :, None], ids[:, None, :]] local_z = z[ids[:, :, None], ids[:, None, :]] @@ -213,6 +217,7 @@ def analyze(weight, names, groups, *, neighbours=12, seed=42, metadata=None, inc "stable_neighbour_distance": array_payload(stable_near.numpy(), dtype="1200||y<0||y>projectionHeight)continue; - ctx.fillStyle='#7c90a34d';ctx.beginPath();ctx.arc(x,y,2,0,2*Math.PI);ctx.fill(); + ctx.fillStyle='#7c90a34d';ctx.beginPath();ctx.arc(x,y,2*pixels,0,2*Math.PI);ctx.fill(); } const [sx,sy]=projectionHits[selected]; if($('projection-edges').checked){ctx.strokeStyle='#087e8b88';for(const i of original){const [x,y]=projectionHits[i];ctx.beginPath();ctx.moveTo(sx,sy);ctx.lineTo(x,y);ctx.stroke();}} - for(const i of projected){if(original.has(i))continue;const [x,y]=projectionHits[i];ctx.strokeStyle='#c87719';ctx.lineWidth=1.8;ctx.beginPath();ctx.arc(x,y,5,0,2*Math.PI);ctx.stroke();} - ctx.font='12px system-ui'; - for(const i of original){const [x,y]=projectionHits[i];ctx.fillStyle='#087e8b';ctx.beginPath();ctx.arc(x,y,4.5,0,2*Math.PI);ctx.fill();if(!$('map-photos').checked)ctx.fillText(data.names[i],x+7,y-5);} - ctx.fillStyle='#c2344b';ctx.beginPath();ctx.arc(sx,sy,6,0,2*Math.PI);ctx.fill();ctx.font='bold 13px system-ui';if(!$('map-photos').checked)ctx.fillText(data.names[selected],sx+9,sy+17); + for(const i of projected){if(original.has(i))continue;const [x,y]=projectionHits[i];ctx.strokeStyle='#c87719';ctx.lineWidth=1.8*pixels;ctx.beginPath();ctx.arc(x,y,5*pixels,0,2*Math.PI);ctx.stroke();} + ctx.font=`${12*pixels}px system-ui`; + for(const i of original){const [x,y]=projectionHits[i];ctx.fillStyle='#087e8b';ctx.beginPath();ctx.arc(x,y,4.5*pixels,0,2*Math.PI);ctx.fill();if(!$('map-photos').checked)ctx.fillText(classLabel(i),x+7,y-5);} + ctx.fillStyle='#c2344b';ctx.beginPath();ctx.arc(sx,sy,6*pixels,0,2*Math.PI);ctx.fill();ctx.font=`bold ${13*pixels}px system-ui`;if(!$('map-photos').checked)ctx.fillText(classLabel(selected),sx+9,sy+17); const fractions=p.variance_fraction?.map(v=>(v*100).toFixed(2)); const summary=fractions?`Variance retained: ${(100*p.variance_fraction.reduce((a,b)=>a+b,0)).toFixed(2)}% (${p.axes[0]} ${fractions[0]}%, ${p.axes[1]} ${fractions[1]}%). `:`Angular t-SNE · perplexity ${p.parameters.perplexity.toFixed(1)} · seed ${p.parameters.seed}. Map gaps and areas do not measure spherical distances or areas. `; $('projection-quality').textContent=`${summary}Original top-${data.stats.k} neighbours retained among plane top-${data.stats.k}: ${(100*p.mean_retained_fraction).toFixed(1)}% averaged over classes; ${(100*p.retained_fraction[selected]).toFixed(1)}% for selected class. Exact plane-distance ties use class order.`; - if(typeof scheduleMapThumbnails==='function')scheduleMapThumbnails(); + if(refreshThumbnails!==false&&typeof scheduleMapThumbnails==='function')scheduleMapThumbnails(); } function projectionMouse(event){const r=projectionCanvas.getBoundingClientRect();return [(event.clientX-r.left)*1200/r.width,(event.clientY-r.top)*projectionHeight/r.height];} -function projectionHit(point){if(typeof mapThumbnailHit==='function'){const hit=mapThumbnailHit(point);if(hit>=0)return hit;}let best=-1,distance=100;projectionHits.forEach(([x,y],i)=>{const d=(x-point[0])**2+(y-point[1])**2;if(d=0)return hit;}let best=-1,distance=(10*1200/(projectionCanvas.getBoundingClientRect().width||1200))**2;projectionHits.forEach(([x,y],i)=>{const d=(x-point[0])**2+(y-point[1])**2;if(d{if(e.button!==0)return;projectionDrag={point:projectionMouse(e),view:{...projectionView},moved:false};projectionCanvas.setPointerCapture(e.pointerId);}; projectionCanvas.onpointermove=e=>{ const point=projectionMouse(e); @@ -80,7 +82,14 @@ new ResizeObserver(()=>{ if(Math.abs(nextHeight-projectionHeight)<.01&&Math.abs(rect.width-projectionScreenWidth)<.1)return; if(projectionScreenWidth)projectionView.scale*=projectionScreenWidth/rect.width; projectionScreenWidth=rect.width;projectionHeight=nextHeight; - projectionCanvas.height=Math.round(nextHeight); - projectionCanvas.getContext('2d').setTransform(1,0,0,projectionCanvas.height/projectionHeight,0,0); + resizeProjectionRaster(); drawProjection(); }).observe(projectionCanvas); + +function resizeProjectionRaster(){ + const rect=projectionCanvas.getBoundingClientRect();if(!rect.width||!rect.height)return; + const dpr=window.devicePixelRatio||1,w=Math.round(rect.width*dpr),h=Math.round(rect.height*dpr); + if(projectionCanvas.width!==w||projectionCanvas.height!==h){projectionCanvas.width=w;projectionCanvas.height=h;} + projectionCanvas.getContext('2d').setTransform(w/1200,0,0,h/projectionHeight,0,0); +} +window.addEventListener('resize',()=>{if(projectionCanvas.getBoundingClientRect().width)drawProjection();}); diff --git a/mini_trainer/visualization/prototype_space/report.html b/mini_trainer/visualization/prototype_space/report.html index bf70a2e7..6d801a5e 100644 --- a/mini_trainer/visualization/prototype_space/report.html +++ b/mini_trainer/visualization/prototype_space/report.html @@ -26,7 +26,18 @@ body.focused #overview-charts{display:contents} body.focused main .panel{min-width:0;margin:0;flex:1 1 480px;max-width:100%} body.focused main .primary-view{order:-1;flex-basis:100%} -body.focused #projection-map{aspect-ratio:auto!important;height:max(300px,calc(100dvh - 340px));border:0!important} +body.focused #projection-panel.primary-view,body.focused #tree-panel.primary-view{height:max(560px,calc(100dvh - var(--toolbar-height,100px) - 32px));display:flex;flex-direction:column} +body.focused .primary-view>p.muted{display:none} +body.focused #projection-panel.primary-view #projection-stage{flex:1;min-height:300px} +body.focused #projection-panel.primary-view #projection-map{aspect-ratio:auto!important;height:100%!important;border:0!important} +body.focused #tree-panel.primary-view .tree-wrap{flex:1;min-height:300px;max-height:none} +body.focused #projection-panel.primary-view .caption{margin:3px 0;font-size:12px} +body.focused #thumbnail-options{margin:4px 0 8px} +body.focused #score-note{flex-basis:100%;order:-2} +.controls input[type=checkbox]{width:auto} +#name-status{max-width:260px} +.label-settings{position:relative;margin:0}.label-settings>div{position:absolute;top:100%;left:0;width:330px;max-width:90vw;padding:16px;display:grid;gap:10px;background:white;border:1px solid var(--line);box-shadow:0 3px 12px #0002;z-index:30} +@media(max-width:600px){.label-settings>div{position:fixed;top:auto;left:8px;right:8px;width:auto}} body.focused .tree-wrap{max-height:calc(100dvh - 210px);min-height:300px;overflow:auto} body.focused #matrix-panel .matrix{max-width:min(100%,calc(100dvh - 350px));margin:auto} .panel-focus{font-size:12px;float:right;margin:0 0 8px 12px} @@ -38,10 +49,14 @@ Save HTML report - - + + +
Class names
+ +
+

@@ -50,7 +65,7 @@

Prototype space · spatial projection

Compare linear PCA with angular t-SNE when available. Scroll to zoom, drag to pan, click a point or thumbnail to inspect its class. Each case is fitted separately.

-
+
Thumbnail settings

Hover a point for its class ID.

Red: selected class · teal: its neighbours in the original space · orange rings: nearby in this plane only · grey: other classes. Links follow the original-space ranking. Projected gaps and apparent overlaps can differ from the full embedding geometry.

@@ -67,9 +82,9 @@

-

Local observatory ·

+

Local observatory ·

-

Hover a cell for its exact stored values; click to select its column class.

Nearest directions

Ranked by repository z-score, descending. This explicitly refines ties in baseline distance; exactly tied z-scores retain checkpoint class order.

RankClass IDBaseline dlog₁₀ tailzSame parent
+

Hover a cell for its exact stored values; click to select its column class.

Nearest directions

Ranked by repository z-score, descending. This explicitly refines ties in baseline distance; exactly tied z-scores retain checkpoint class order.

RankClass IDBaseline dlog₁₀ tailzSame parent

Questions this opens up

With prototype weights alone

Compare local crowding across taxonomy; follow cross-branch nearest neighbours; measure how many baseline distances tie; examine neighbour stability across checkpoints. Keep alternate metrics alongside the reference rather than silently replacing it.

With held-out image embeddings

Add sample-to-prototype alignment, decision margins, observed occupancy and actual confusion. Those quantities are not inferred from this weight-only report. A sampled decision-territory experiment would be a separate geometric reference.

Provenance and limits

Class and parent IDs come from checkpoint metadata; no online taxonomic remapping. A single checkpoint does not establish a training trajectory. Synthetic cases are deliberately constructed display/calculation probes, not targets. Their smaller class counts do not provide a matched packing comparison. Zero distance may result from CDF rounding and does not prove identical vectors. Grey matrix cells contain no off-diagonal pairs; display flooring never alters stored numerical values.

Self-contained numerical report · optional GBIF photo lookup when enabled · computations use mini_trainer’s class_similarity and class_distance APIs.
@@ -82,6 +97,62 @@ function el(tag, attrs={}, text='') {const n=document.createElementNS(ns,tag); for(const [k,v] of Object.entries(attrs)) n.setAttribute(k,v); if(text!=='') n.textContent=text; return n;} function add(svg,tag,attrs,text) {const n=el(tag,attrs,text);svg.append(n);return n;} function decode(s,bits=32){const raw=atob(s), bytes=Uint8Array.from(raw,c=>c.charCodeAt(0));const view=new DataView(bytes.buffer),width=bits/8;return (bits===32?Float32Array:Float64Array).from({length:bytes.length/width},(_,i)=>bits===32?view.getFloat32(i*width,true):view.getFloat64(i*width,true));} +// Labels are presentation metadata: row IDs and numerical arrays never change. +const resolvedNames=new Map(),importedNames=new WeakMap(),nameFailures=new Set(); +let nameBusy=false,nameTimer=null,labelRefreshTimer=null; +function classLabel(index){ + const id=data.names[index];if(!$('class-names').checked)return id; + return importedNames.get(data)?.get(index)||data.display_names?.[index]||($('gbif-names').checked?resolvedNames.get(id):undefined)||id; +} +function classIdentity(index){const label=classLabel(index),id=data.names[index];return label===id?id:`${label} [${id}]`;} +function markClass(node,index){node.dataset.classIndex=index;node.append(el('title',{},classIdentity(index)));return node;} +function rebuildClassOptions(){ + const options=document.createDocumentFragment(); + data.names.forEach((id,i)=>{const o=document.createElement('option');o.value=classIdentity(i);o.label=`row ${i} · ${id}`;options.append(o);}); + $('classes').replaceChildren(options); +} +function refreshClassLabels(){ + drawProfile();drawTree();drawLocal();$('selected-name').textContent=classIdentity(selected); + if(document.activeElement!==$('search'))$('search').value=classIdentity(selected); + rebuildClassOptions();if(typeof drawProjection==='function'&&projectionCanvas.getBoundingClientRect().width)drawProjection(false); +} +function requestClassNames(){ + clearTimeout(nameTimer);nameTimer=setTimeout(resolveVisibleNames,100); +} +async function resolveVisibleNames(){ + if(nameBusy||!$('class-names').checked||!$('gbif-names').checked||data.metadata.synthetic)return; + const priority=[]; + for(const node of document.querySelectorAll('[data-class-index]')){ + const r=node.getBoundingClientRect();if(r.width&&r.height&&r.bottom>=0&&r.top/^\d+$/.test(data.names[i])&&!resolvedNames.has(data.names[i])&&!nameFailures.has(data.names[i])&&!importedNames.get(data)?.has(i)); + if(candidate===undefined){$('name-status').textContent='Visible names resolved where available. Search IDs or resolved names; scroll to resolve more.';return;} + const id=data.names[candidate];nameBusy=true;$('name-status').textContent='Resolving visible GBIF names…'; + try{ + const response=await fetch('/api/taxon/'+encodeURIComponent(id),{signal:AbortSignal.timeout(12000)}); + if(!response.ok)throw Error('Name lookup unavailable');const value=await response.json(); + if(value.class_id!==id||typeof value.display_name!=='string')throw Error('Invalid name response'); + resolvedNames.set(id,value.display_name); + if($('class-names').checked){clearTimeout(labelRefreshTimer);labelRefreshTimer=setTimeout(refreshClassLabels,80);} + }catch(error){nameFailures.add(id);$('name-status').textContent='Some names are unavailable. IDs remain visible; use the local server or import a name map.';} + finally{nameBusy=false;requestClassNames();} +} +// Approximation to log Phi(-z), evaluated without forming 1-CDF(z). +// The erfc approximation is independently checked against torch log_ndtr. +function referenceLogTail(z){ + const x=Math.abs(z)/Math.SQRT2,t=1/(1+.5*x); + const logSmall=Math.log(t)-x*x-1.26551223+t*(1.00002368+t*(.37409196+t*(.09678418+t*(-.18628806+t*(.27886807+t*(-1.13520398+t*(1.48851587+t*(-.82215223+t*.17087277))))))))-Math.LN2; + return z>=0?logSmall:Math.log1p(-Math.exp(logSmall)); +} +function probabilityText(log10){ + if(log10===-Infinity)return '0'; + if(log10< -4){const exponent=Math.floor(log10);return `${Math.pow(10,log10-exponent).toFixed(3)}e${exponent}`;} + if(log10>Math.log10(.9999)){const complement=-Math.expm1(log10*Math.LN10);return complement?`1 − ${complement.toExponential(3)}`:'1';} + return Math.pow(10,log10).toPrecision(5); +} +function scoreText(z){return $('score-display').value==='tail'?probabilityText(referenceLogTail(z)/Math.LN10):fmt(z);} + function fmt(x){return x===0?'0':Math.abs(x)<.001?x.toExponential(3):x.toFixed(4);} function median(xs){const x=xs.slice().sort((a,b)=>a-b),m=Math.floor(x.length/2);return x.length%2?x[m]:(x[m-1]+x[m])/2;} let data, selected=0, distances, zs, logtails, stableNear, globalValues, parents, starts, ends, positions, treeFocus, profileMedian; @@ -102,22 +173,37 @@ for(const [value,label] of [[n.toLocaleString(),'class prototypes'],[data.stats.dimensions.toLocaleString(),'embedding dimensions'],[data.stats.off_diagonal_zero_distance_pairs.toLocaleString(),'unordered pairs with baseline distance 0'],[data.stats.nearest_z_median.toFixed(2),'median nearest-neighbour z']]){const d=document.createElement('div');d.className='stat';const b=document.createElement('strong'),s=document.createElement('span');b.textContent=value;s.textContent=label;d.append(b,s);$('stats').append(d);} setRangePreset(); $('provenance').textContent=JSON.stringify({metadata:data.metadata,statistics:data.stats},null,2); - drawHistogram();selectClass(0);$('search-status').textContent=''; + drawHistogram();rebuildClassOptions();selectClass(0);$('search-status').textContent=''; } function axes(svg,xlabel,ylabel){svg.replaceChildren();add(svg,'path',{d:'M 52 15 V 195 H 540',fill:'none',stroke:'#a5b7c5'});add(svg,'text',{x:295,y:233,'text-anchor':'middle',fill:'#52687b','font-size':12},xlabel);add(svg,'text',{x:54,y:12,fill:'#52687b','font-size':11},ylabel);} -function drawHistogram(){const svg=$('hist'),h=data.histogram;axes(svg,'Pairwise z-score','log₁₀(pair count + 1)');const max=Math.max(...h.counts),width=488/h.counts.length;h.counts.forEach((count,i)=>{const y=180*Math.log10(count+1)/Math.log10(max+1);add(svg,'rect',{x:52+i*width,y:195-y,width:Math.max(.5,width-.3),height:y,fill:'#168b92'});});for(let i=0;i<=4;i++){const x=52+488*i/4;add(svg,'text',{x,y:215,'text-anchor':'middle','font-size':11,fill:'#52687b'},(h.edges[0]+(h.edges.at(-1)-h.edges[0])*i/4).toFixed(1));}for(let i=0;i<=3;i++)add(svg,'text',{x:47,y:198-i*60,'text-anchor':'end','font-size':11,fill:'#52687b'},(Math.log10(max+1)*i/3).toFixed(1));$('hist-note').textContent=`${data.stats.pair_sampling}; ${data.stats.pair_sample_count.toLocaleString()} pairs. Mean ${fmt(data.stats.pair_z_mean)}, SD ${fmt(data.stats.pair_z_std)}. ${data.stats.histogram_excluded} pairs outside the displayed range.`;} -function drawProfile(){const svg=$('profile'),p=data.profiles[selected],lo=Math.min(0,...p,...profileMedian),hi=Math.max(1,...p,...profileMedian);axes(svg,'Neighbour rank','z-score');const y=v=>195-170*(v-lo)/(hi-lo);for(let i=0;i<4;i++){add(svg,'text',{x:60+i*155,y:215,'text-anchor':'middle','font-size':11},data.profile_ranks[i]);}for(const [vals,col] of [[profileMedian,'#aab8c5'],[p,'#087e8b']]){add(svg,'polyline',{points:vals.map((v,i)=>`${60+i*155},${y(v)}`).join(' '),fill:'none',stroke:col,'stroke-width':2.5});vals.forEach((v,i)=>add(svg,'circle',{cx:60+i*155,cy:y(v),r:4,fill:col}));}for(let i=0;i<4;i++){const v=lo+(hi-lo)*i/3;add(svg,'text',{x:47,y:y(v)+4,'text-anchor':'end','font-size':11},v.toFixed(1));}} +function drawHistogram(){const svg=$('hist'),h=data.histogram;axes(svg,$('score-display').value==='tail'?'Upper-tail q (positions retain z bins)':'Pairwise z-score','log₁₀(pair count + 1)');const max=Math.max(...h.counts),width=488/h.counts.length;h.counts.forEach((count,i)=>{const y=180*Math.log10(count+1)/Math.log10(max+1);add(svg,'rect',{x:52+i*width,y:195-y,width:Math.max(.5,width-.3),height:y,fill:'#168b92'});});for(let i=0;i<=4;i++){const x=52+488*i/4;add(svg,'text',{x,y:215,'text-anchor':'middle','font-size':11,fill:'#52687b'},scoreText(h.edges[0]+(h.edges.at(-1)-h.edges[0])*i/4));}for(let i=0;i<=3;i++)add(svg,'text',{x:47,y:198-i*60,'text-anchor':'end','font-size':11,fill:'#52687b'},(Math.log10(max+1)*i/3).toFixed(1));$('hist-note').textContent=`${data.stats.pair_sampling}; ${data.stats.pair_sample_count.toLocaleString()} pairs. Mean ${fmt(data.stats.pair_z_mean)}, SD ${fmt(data.stats.pair_z_std)}. ${data.stats.histogram_excluded} pairs outside the displayed range.`;} +function drawProfile(){ + const svg=$('profile'),tail=$('score-display').value==='tail',convert=z=>tail?-referenceLogTail(z)/Math.LN10:z; + const p=data.profiles[selected].map(convert),med=profileMedian.map(convert),lo=Math.min(0,...p,...med),hi=Math.max(1,...p,...med); + axes(svg,'Neighbour rank',''+(tail?'Upper-tail q (log scale)':'z-score'));svg.setAttribute('viewBox','0 0 560 310'); + const y=v=>195-170*(v-lo)/(hi-lo); + for(let i=0;i<4;i++){ + const rank=data.profile_ranks[i],id=data.profile_neighbours?.[selected]?.[i]??data.neighbours[selected][rank-1]; + add(svg,'text',{x:60+i*155,y:215,'text-anchor':'middle','font-size':11},rank); + if(id!==undefined){const label=classLabel(id);markClass(add(svg,'text',{x:60+i*150,y:257,'text-anchor':'middle','font-size':10},label.length>18?label.slice(0,17)+'…':label),id);} + } + for(const [vals,col] of [[med,'#aab8c5'],[p,'#087e8b']]){ + add(svg,'polyline',{points:vals.map((v,i)=>`${60+i*155},${y(v)}`).join(' '),fill:'none',stroke:col,'stroke-width':2.5}); + vals.forEach((v,i)=>add(svg,'circle',{cx:60+i*155,cy:y(v),r:4,fill:col}).append(el('title',{},tail?probabilityText(-v):fmt(v)))); + } + for(let i=0;i<4;i++){const v=lo+(hi-lo)*i/3;add(svg,'text',{x:47,y:y(v)+4,'text-anchor':'end','font-size':10},tail?probabilityText(-v):v.toFixed(1));} +} function drawTree(){ const svg=$('tree'),n=data.names.length;svg.replaceChildren();let frontier=[treeFocus]; while(frontier.length<32){let at=-1;frontier.forEach((node,i)=>{if(node>=n&&(at<0||height(node)>height(frontier[at])))at=i;});if(at<0)break;frontier.splice(at,1,...children(frontier[at]));} frontier.sort((a,b)=>starts[a]-starts[b]);const tips=new Set(frontier),ys=new Map(frontier.map((node,i)=>[node,30+i*19])); const focused=document.body.dataset.view==='tree',treeWidth=focused?Math.max(760,svg.parentElement.clientWidth):1200; - const totalHeight=Math.max(100,frontier.length*19+50,focused?innerHeight-260:0); + const totalHeight=Math.max(100,frontier.length*19+50,focused?svg.parentElement.clientHeight:0); if(focused)frontier.forEach((node,i)=>ys.set(node,30+i*(totalHeight-60)/Math.max(1,frontier.length-1))); const branchWidth=treeWidth-410;svg.setAttribute('viewBox',`0 0 ${treeWidth} ${totalHeight}`); const max=height(treeFocus),x=node=>max>0?24+branchWidth*(1-height(node)/max):24+branchWidth*(1-size(node)/Math.max(1,size(treeFocus))); - function draw(node){if(!tips.has(node)){const [a,b]=children(node);draw(a);draw(b);ys.set(node,(ys.get(a)+ys.get(b))/2);for(const child of [a,b])add(svg,'path',{d:`M ${x(node)} ${ys.get(node)} V ${ys.get(child)} H ${x(child)}`,fill:'none',stroke:contains(child,selected)?'#008e9b':'#b3c1cd','stroke-width':contains(child,selected)?2:1});}else{const y=ys.get(node),label=node{if(node{if(e.key==='Enter')action();};if(contains(node,selected))add(svg,'circle',{cx:x(node),cy:y,r:4,fill:'#087e8b'});}} - draw(treeFocus);$('tree-note').textContent=`Showing ${size(treeFocus).toLocaleString()} classes · root height ${fmt(max)} · ${frontier.length} visible tips${max===0?' · all heights zero: horizontal positions use class counts for display':''}`; + function draw(node){if(!tips.has(node)){const [a,b]=children(node);draw(a);draw(b);ys.set(node,(ys.get(a)+ys.get(b))/2);for(const child of [a,b])add(svg,'path',{d:`M ${x(node)} ${ys.get(node)} V ${ys.get(child)} H ${x(child)}`,fill:'none',stroke:contains(child,selected)?'#008e9b':'#b3c1cd','stroke-width':contains(child,selected)?2:1});}else{const y=ys.get(node),label=node{if(node{if(e.key==='Enter')action();};if(contains(node,selected))add(svg,'circle',{cx:x(node),cy:y,r:4,fill:'#087e8b'});}} + draw(treeFocus);requestClassNames();$('tree-note').textContent=`Showing ${size(treeFocus).toLocaleString()} classes · root height ${fmt(max)} · ${frontier.length} visible tips${max===0?' · all heights zero: horizontal positions use class counts for display':''}`; } function setRangePreset(){const mode=$('range-preset').value;let bounds;if(mode==='focus')bounds=[-8,0];else if(mode==='robust')bounds=data.robust_log10_range;else if(mode==='full')bounds=[data.matrix_log10_floor,0];if(bounds){$('range-low').value=bounds[0].toFixed(3);$('range-high').value=bounds[1].toFixed(3);}drawGlobal();} function drawGlobal(){ @@ -132,17 +218,19 @@ $('global-ticks').replaceChildren();for(let i=0;i<=4;i++){const span=document.createElement('span');span.textContent=(i===0?'≤ ':i===4?'≥ ':'')+`10^${(lo+(hi-lo)*i/4).toFixed(1)}`;$('global-ticks').append(span);} $('matrix-note').textContent=`Each pixel summarizes up to ${data.matrix_block} × ${data.matrix_block} classes. Cyan lines locate the selected class’s block. Both panels share the selected colour window; full numerical values remain available. ${useLog?'Float32 log-domain values.':'Legacy CDF rounding is retained for comparison.'}`; } -function selectClass(id){selected=id;$('search').value=data.names[id];$('selected-name').textContent=data.names[id];$('selected-meta').textContent=`Checkpoint row ${id} · taxonomy IDs: ${data.groups[id].join(' → ')}`;$('ties').textContent=`${data.zero_count[id]} other classes have baseline distance 0 to this class. Neighbour ranks below use the pre-CDF z-score; the baseline distances are unchanged. The log-tail view evaluates log Φ(−z) directly in float32, retaining the repository z-score. Hover a baseline distance in the table for a separate float64 reference.`;drawProfile();drawTree();drawLocal();if(typeof renderPhotos==='function')renderPhotos();if(typeof drawProjection==='function')drawProjection();const pos=(positions[id]+.5)/(Math.ceil(data.names.length/data.matrix_block)*data.matrix_block)*100;document.querySelectorAll('.marker:not(.horizontal)').forEach(m=>m.style.left=pos+'%');document.querySelectorAll('.marker.horizontal').forEach(m=>m.style.top=pos+'%');} +function selectClass(id){selected=id;$('search').value=classIdentity(id);$('selected-name').textContent=classIdentity(id);$('selected-meta').textContent=`Checkpoint row ${id} · taxonomy IDs: ${data.groups[id].join(' → ')}`;$('ties').textContent=`${data.zero_count[id]} other classes have baseline distance 0 to this class. Neighbour ranks below use the pre-CDF z-score; the baseline distances are unchanged. The log-tail view evaluates log Φ(−z) directly in float32, retaining the repository z-score. Hover a baseline distance in the table for a separate float64 reference.`;drawProfile();drawTree();drawLocal();requestClassNames();if(typeof renderPhotos==='function')renderPhotos();if(typeof drawProjection==='function')drawProjection();const pos=(positions[id]+.5)/(Math.ceil(data.names.length/data.matrix_block)*data.matrix_block)*100;document.querySelectorAll('.marker:not(.horizontal)').forEach(m=>m.style.left=pos+'%');document.querySelectorAll('.marker.horizontal').forEach(m=>m.style.top=pos+'%');} function drawLocal(){ - const svg=$('local');svg.replaceChildren();const ids=[selected,...data.neighbours[selected]],n=ids.length,stride=n*n,offset=selected*stride,mode=$('metric').value,useZ=mode!=='distance',arr=mode==='z'?zs:mode==='logtail'?Float32Array.from(logtails,v=>-v):distances,vals=Array.from(arr.slice(offset,offset+stride));const finite=vals.filter((_,i)=>Math.floor(i/n)!==i%n),lo=useZ?Math.min(...finite):0,hi=Math.max(...finite,lo+1e-9),cell=400/n; + const svg=$('local');svg.replaceChildren();const ids=[selected,...data.neighbours[selected]],n=ids.length,stride=n*n,offset=selected*stride,mode=$('metric').value,useZ=mode!=='distance',arr=mode==='z'?zs:(mode==='logtail'||mode==='tail')?Float32Array.from(logtails,v=>-v):distances,vals=Array.from(arr.slice(offset,offset+stride));const finite=vals.filter((_,i)=>Math.floor(i/n)!==i%n),lo=useZ?Math.min(...finite):0,hi=Math.max(...finite,lo+1e-9),cell=400/n,labelled=$('class-names').checked,margin=labelled?175:62;svg.setAttribute('viewBox',`0 0 ${margin+418} ${margin+418}`); const palette=t=>`hsl(${220-190*t} 65% ${94-55*t}%)`; - ids.forEach((_,i)=>{add(svg,'text',{x:62+(i+.5)*cell,y:47,'text-anchor':'middle','font-size':11},i);add(svg,'text',{x:52,y:62+(i+.5)*cell+4,'text-anchor':'end','font-size':11},i);}); - for(let r=0;r{$('cell-info').textContent=label;};rect.onclick=()=>selectClass(ids[c]);} - $('cell-info').textContent='Row/column 0 is the selected class; remaining numbers match neighbour ranks. Hover for stored values.'; + ids.forEach((id,i)=>{const text=labelled?classLabel(id):String(i);const top=markClass(add(svg,'text',{x:margin+(i+.5)*cell,y:margin-12,'text-anchor':labelled?'start':'middle','font-size':11},text.length>25?text.slice(0,24)+'…':text),id);if(labelled)top.setAttribute('transform',`rotate(-55 ${margin+(i+.5)*cell} ${margin-12})`);markClass(add(svg,'text',{x:margin-10,y:margin+(i+.5)*cell+4,'text-anchor':'end','font-size':11},text.length>25?text.slice(0,24)+'…':text),id);}); + for(let r=0;r{$('cell-info').textContent=label;};rect.onclick=()=>selectClass(ids[c]);} + $('cell-info').textContent='First row/column is the selected class; remaining entries follow neighbour rank. Hover for stored values.'; $('local-scale').textContent=useZ?`Local colour range: ${mode==='z'?'z':'−log₁₀ tail'} ${fmt(lo)} (pale) → ${fmt(hi)} (dark). Diagonal masked. Scale adapts to this neighbourhood.`:`Local colour range: distance 0 (dark) → ${fmt(hi)} (pale), log1p display. Diagonal masked. Scale adapts to this neighbourhood.`; - $('neighbours').replaceChildren();ids.slice(1).forEach((id,i)=>{const tr=document.createElement('tr');const parent=data.groups[selected][1],same=parent===undefined?'—':data.groups[id][1]===parent?'yes':'no';[i+1,data.names[id],fmt(distances[offset+i+1]),fmt(logtails[offset+i+1]),fmt(zs[offset+i+1]),same].forEach((value,j)=>{const td=document.createElement('td');if(j===1){const b=document.createElement('button');b.textContent=value;b.onclick=()=>selectClass(id);td.append(b);}else td.textContent=value;if(j===2)td.title=`Float64 reference d: ${fmt(stableNear[selected*(n-1)+i])}`;tr.append(td);});$('neighbours').append(tr);}); + if(mode==='tail')$('local-scale').textContent=`Upper-tail q: ${probabilityText(-lo)} → ${probabilityText(-hi)}; colours use −log₁₀ q. Diagonal masked.`; + $('score-column').textContent=$('score-display').value==='tail'?'q = Φ(−z)':'z'; + $('neighbours').replaceChildren();ids.slice(1).forEach((id,i)=>{const tr=document.createElement('tr');const parent=data.groups[selected][1],same=parent===undefined?'—':data.groups[id][1]===parent?'yes':'no';[i+1,classIdentity(id),fmt(distances[offset+i+1]),fmt(logtails[offset+i+1]),scoreText(zs[offset+i+1]),same].forEach((value,j)=>{const td=document.createElement('td');if(j===1){const b=document.createElement('button');b.textContent=value;b.dataset.classIndex=id;b.title=`Class ${data.names[id]} · row ${id}`;b.onclick=()=>selectClass(id);td.append(b);}else td.textContent=value;if(j===2)td.title=`Float64 reference d: ${fmt(stableNear[selected*(n-1)+i])}`;tr.append(td);});$('neighbours').append(tr);}); } -$('find').onclick=()=>{const query=$('search').value.trim();const exact=data.names.indexOf(query),id=exact>=0?exact:data.names.findIndex(n=>n.includes(query));if(!query||id<0){$('search-status').textContent='No matching class ID.';return;}$('search-status').textContent=exact>=0?'Exact match.':'First partial match.';selectClass(id);}; +$('find').onclick=()=>{const query=$('search').value.trim();const exact=data.names.indexOf(query),row=/^row \d+$/i.test(query)?Number(query.slice(4)):-1;const id=exact>=0?exact:row>=0&&rowclassIdentity(i).toLowerCase().includes(query.toLowerCase()));if(!query||id<0){$('search-status').textContent='No matching class ID.';return;}$('search-status').textContent=exact>=0?'Exact match.':'First partial match.';selectClass(id);}; $('search').onkeydown=e=>{if(e.key==='Enter')$('find').click();}; $('crowded').onclick=()=>selectClass(data.profiles.reduce((best,p,i)=>p[0]>data.profiles[best][0]?i:best,0)); $('sparse').onclick=()=>selectClass(data.profiles.reduce((best,p,i)=>p[0]button.hidden=view!=='all'); - drawTree();window.scrollTo(0,0); + drawTree();window.scrollTo(0,0);requestClassNames(); } for(const [view,ids] of Object.entries(viewGroups)){ const button=document.createElement('button');button.className='panel-focus';button.textContent='Focus view'; @@ -179,3 +268,23 @@ $('workspace-view').onchange=()=>setWorkspaceView($('workspace-view').value); new ResizeObserver(()=>{if(document.body.dataset.view==='tree')drawTree();}).observe($('tree').parentElement); + + + + diff --git a/mini_trainer/visualization/prototype_space/serve.py b/mini_trainer/visualization/prototype_space/serve.py index 88406363..9578d857 100644 --- a/mini_trainer/visualization/prototype_space/serve.py +++ b/mini_trainer/visualization/prototype_space/serve.py @@ -90,6 +90,14 @@ def __init__(self, allowed_ids, cache): # Serialize all upstream requests, including image proxy requests. self.lock = threading.Lock() + def taxon_name(self, class_id): + """Resolve a name without occurrence or image lookups; preserve the ID.""" + if class_id not in self.allowed_ids or not class_id.isdigit(): + raise ValueError("Class is not a GBIF-ID candidate in this report") + with self.lock: + taxon = gbif.retrive_request(f"{gbif.GBIF_SPECIES_API_ENDPOINT}{class_id}") + return {"class_id": class_id, "display_name": taxon.get("canonicalName") or taxon.get("scientificName") or class_id} + def metadata(self, class_id): if class_id not in self.allowed_ids or not class_id.isdigit(): raise ValueError("Class is not a GBIF-ID candidate in this report") @@ -148,6 +156,8 @@ def do_GET(self): try: if path == "/api/health": return self.send_json({"photo_api": True}) + if path.startswith("/api/taxon/"): + return self.send_json(self.service.taxon_name(path.removeprefix("/api/taxon/"))) if path.startswith("/api/gbif/"): return self.send_json(self.service.metadata(path.removeprefix("/api/gbif/"))) if re.fullmatch(r"/gbif-image/\d+/[a-f0-9]{32}", path): diff --git a/tests/utils/test_prototype_exploration.py b/tests/utils/test_prototype_exploration.py index 87317b5f..a5d38038 100644 --- a/tests/utils/test_prototype_exploration.py +++ b/tests/utils/test_prototype_exploration.py @@ -62,6 +62,11 @@ def test_algebraic_cases_keep_baseline_ties_and_original_submatrix_values(): names = [f"class-{i}" for i in range(len(weight))] result = analyze(weight, names, [[name] for name in names], neighbours=3) assert result["neighbours"][0][:2] == [1, 5] + for i, ranks in enumerate(result["profile_neighbours"]): + row = z[i].copy() + row[i] = -np.inf + expected = np.argsort(-row, kind="stable")[np.asarray(result["profile_ranks"]) - 1] + assert ranks == expected.tolist() assert all(i not in row for i, row in enumerate(result["neighbours"])) assert result["zero_count"][0] == 2 assert result["stats"]["pair_sample_count"] == 15 diff --git a/tests/utils/test_prototype_photos.py b/tests/utils/test_prototype_photos.py index a35899dc..bdfdb371 100644 --- a/tests/utils/test_prototype_photos.py +++ b/tests/utils/test_prototype_photos.py @@ -73,3 +73,19 @@ def cached_requests(): thread.join(timeout=2) assert bypassed, "Cached responses must not wait behind an upstream download" assert results == [album, (b"cached", "image/jpeg")] + + +def test_name_lookup_does_not_request_occurrences_or_images(monkeypatch): + calls = [] + + def retrieve(url): + calls.append(url) + return {"canonicalName": "Example taxon", "acceptedKey": 99} + + monkeypatch.setattr(serve.gbif, "retrive_request", retrieve) + service = serve.PhotoService({"42"}, {}) + assert service.taxon_name("42") == {"class_id": "42", "display_name": "Example taxon"} + assert calls == [serve.gbif.GBIF_SPECIES_API_ENDPOINT + "42"] + assert not service.image_paths + with pytest.raises(ValueError): + service.taxon_name("43") From d1e382112e88869c45110b164d356b4c0074d541 Mon Sep 17 00:00:00 2001 From: asgersvenning Date: Fri, 11 Sep 2026 11:32:33 +0200 Subject: [PATCH 23/34] fix: keep projection geometry stable during hover updates --- dev/prototype_space/check_browser.mjs | 14 ++++++++++++++ docs/prototype-explorer-roadmap.md | 3 ++- docs/prototype-explorer.md | 4 +++- .../visualization/prototype_space/report.html | 14 +++++++++++--- 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/dev/prototype_space/check_browser.mjs b/dev/prototype_space/check_browser.mjs index 50993b9a..81f0af7c 100644 --- a/dev/prototype_space/check_browser.mjs +++ b/dev/prototype_space/check_browser.mjs @@ -271,6 +271,20 @@ try{ check($('selected-name').textContent===data.names[selected],'IDs restore without losing selected class'); const rect=projectionCanvas.getBoundingClientRect();check(projectionCanvas.width===Math.round(rect.width*devicePixelRatio)&&projectionCanvas.height===Math.round(rect.height*devicePixelRatio),'canvas backing matches physical screen pixels'); check(rect.height>=300,'focused map retains vertical workspace'); + const geometry=()=>{const r=projectionCanvas.getBoundingClientRect();return JSON.stringify([r.x,r.y,r.width,r.height,projectionView.x,projectionView.y,projectionView.scale]);}; + const stableGeometry=geometry(),savedCredit=$('map-photo-credit').innerHTML; + for(const expanded of [false,true]){ + $('projection-details').open=expanded; + for(const text of ['', 'Long taxon name and attribution '.repeat(100),'Short credit']){ + $('map-photo-credit').textContent=text;$('map-photo-status').textContent=text; + await new Promise(r=>setTimeout(r,60)); + check(geometry()===stableGeometry,'hover and status preserve map geometry with details '+expanded); + } + } + $('thumbnail-options').open=true;await new Promise(r=>setTimeout(r,60)); + check(geometry()===stableGeometry,'thumbnail settings overlay preserves map geometry'); + $('thumbnail-options').open=false;$('projection-details').open=false;$('map-photo-credit').innerHTML=savedCredit; + check(rect.height>innerHeight*.75,'focused map uses over three quarters of landscape height'); return checks; })()`,returnByValue:true,awaitPromise:true}); if(enhanced.exceptionDetails)throw Error(JSON.stringify(enhanced.exceptionDetails)); diff --git a/docs/prototype-explorer-roadmap.md b/docs/prototype-explorer-roadmap.md index 9880d4dc..eaab4fdd 100644 --- a/docs/prototype-explorer-roadmap.md +++ b/docs/prototype-explorer-roadmap.md @@ -20,7 +20,8 @@ development branch; reusable-package baseline `4763041`): pan/zoom retention, and quick navigation exit fades. - Optional asynchronous taxon names with visible-label priority and offline aliases; z/chance-alignment probability views with explicit reference semantics. -- Display-density projection rendering and viewport-height focused panels. +- Display-density projection rendering and viewport-height focused panels; map + details and settings overlays prevent hover/status text from resizing the canvas. Evidence: real checkpoint diagnostics match the preserved baseline; 84 browser checks, 12 affected Python tests, motion checks, static checks, and installed-wheel diff --git a/docs/prototype-explorer.md b/docs/prototype-explorer.md index 68b4131e..7a7229ee 100644 --- a/docs/prototype-explorer.md +++ b/docs/prototype-explorer.md @@ -62,7 +62,9 @@ projection includes the neighbourhood profile, local inspector, and class photos Choose **All views** to return to the overview. Focused projection and dendrogram panels use the available viewport height as -well as width. Thumbnail settings collapse to leave more space for the map. +well as width. Thumbnail settings and **Map details & credits** open as overlays, leaving the +map size fixed while hover credits and loading status change. Focused projection +padding is compact to leave more room for the map. The spatial map adapts its aspect ratio while preserving equal geometric scale on both axes; its raster follows display pixel density with readable screen-size labels. Dendrograms use the wider layout with readable labels and internal scrolling on diff --git a/mini_trainer/visualization/prototype_space/report.html b/mini_trainer/visualization/prototype_space/report.html index 6d801a5e..8d622a77 100644 --- a/mini_trainer/visualization/prototype_space/report.html +++ b/mini_trainer/visualization/prototype_space/report.html @@ -32,7 +32,15 @@ body.focused #projection-panel.primary-view #projection-map{aspect-ratio:auto!important;height:100%!important;border:0!important} body.focused #tree-panel.primary-view .tree-wrap{flex:1;min-height:300px;max-height:none} body.focused #projection-panel.primary-view .caption{margin:3px 0;font-size:12px} -body.focused #thumbnail-options{margin:4px 0 8px} +#projection-panel{position:relative} +#projection-details{position:absolute;bottom:12px;right:12px;z-index:4;margin:0;max-width:calc(100% - 24px);font-size:12px} +#projection-details>summary{width:max-content;margin-left:auto;padding:5px 10px;background:#fffffff2;border:1px solid var(--line);border-radius:6px} +.projection-details-content{position:absolute;bottom:100%;right:0;width:620px;max-width:calc(100vw - 48px);max-height:min(350px,55dvh);overflow:auto;overflow-wrap:anywhere;background:#fffffff5;border:1px solid var(--line);border-radius:8px;padding:12px;box-shadow:0 3px 12px #0002} +body.focused #projection-panel.primary-view{padding:10px;height:max(400px,calc(100dvh - var(--toolbar-height,100px) - 16px))} +body[data-view=projection] main{padding:8px} +body.focused #projection-panel.primary-view h2{font-size:18px;margin:0} +body.focused #thumbnail-options{position:relative;margin:4px 0;flex-shrink:0;font-size:12px;z-index:5} +body.focused #thumbnail-options>div{position:absolute;top:100%;left:0;right:0;margin:0!important;padding:12px;background:#fffffff5;border:1px solid var(--line);border-radius:6px;max-height:45dvh;overflow:auto;box-shadow:0 3px 12px #0002} body.focused #score-note{flex-basis:100%;order:-2} .controls input[type=checkbox]{width:auto} #name-status{max-width:260px} @@ -67,8 +75,8 @@

Compare linear PCA with angular t-SNE when available. Scroll to zoom, drag to pan, click a point or thumbnail to inspect its class. Each case is fitted separately.

Thumbnail settings
-

-

Hover a point for its class ID.

Red: selected class · teal: its neighbours in the original space · orange rings: nearby in this plane only · grey: other classes. Links follow the original-space ranking. Projected gaps and apparent overlaps can differ from the full embedding geometry.

+
Map details & credits

+

Hover a point for its class ID.

Red: selected class · teal: its neighbours in the original space · orange rings: nearby in this plane only · grey: other classes. Links follow the original-space ranking. Projected gaps and apparent overlaps can differ from the full embedding geometry.

Browse by class image

Photo labels for the selected class and its nearest prototype directions. The prototype marks the learned target direction for the class; a GBIF class example provides its visual label. Credits and exact class IDs stay attached.

Explore the existing dendrogram

From 05aafee9590d603295836cef52a8a797d84d0bd1 Mon Sep 17 00:00:00 2001 From: asgersvenning Date: Fri, 11 Sep 2026 11:36:53 +0200 Subject: [PATCH 24/34] feat: fill focused projection viewport with floating controls --- dev/prototype_space/check_browser.mjs | 6 +++++ docs/prototype-explorer-roadmap.md | 1 + docs/prototype-explorer.md | 7 +++-- .../visualization/prototype_space/report.html | 26 ++++++++++++++++--- 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/dev/prototype_space/check_browser.mjs b/dev/prototype_space/check_browser.mjs index 81f0af7c..bb7288ae 100644 --- a/dev/prototype_space/check_browser.mjs +++ b/dev/prototype_space/check_browser.mjs @@ -218,6 +218,12 @@ try{ const rect=projectionCanvas.getBoundingClientRect(); check(! $('inspector').hidden&&!$('photo-panel').hidden&&$('tree-panel').hidden,'projection groups related panels'); check(rect.width>innerWidth*.8&&rect.height>=300,'projection uses viewport width and height'); + check(rect.top===0&&rect.left===0&&Math.abs(rect.height-innerHeight)<1&&Math.abs(rect.width-document.documentElement.clientWidth)<1,'projection fills the viewport'); + $('workspace-controls').open=true;$('projection-controls').open=true;await new Promise(r=>setTimeout(r,60)); + const expanded=projectionCanvas.getBoundingClientRect(); + check(expanded.width===rect.width&&expanded.height===rect.height&&expanded.top===rect.top,'floating controls do not resize map'); + check($('workspace-view').getBoundingClientRect().width>0&&$('projection-plane').getBoundingClientRect().width>0,'view and map settings remain accessible'); + $('workspace-controls').open=false;$('projection-controls').open=false; check(Math.abs(rect.width/1200-rect.height/projectionHeight)<1e-8,'projection preserves equal screen scale'); check(selected===before.selected&&treeFocus===before.treeFocus&&$('projection-plane').value===before.plane,'focus preserves selection tree and plane'); check(projectionView.x===before.x&&projectionView.y===before.y,'focus preserves projection center'); diff --git a/docs/prototype-explorer-roadmap.md b/docs/prototype-explorer-roadmap.md index eaab4fdd..e1eff318 100644 --- a/docs/prototype-explorer-roadmap.md +++ b/docs/prototype-explorer-roadmap.md @@ -22,6 +22,7 @@ development branch; reusable-package baseline `4763041`): z/chance-alignment probability views with explicit reference semantics. - Display-density projection rendering and viewport-height focused panels; map details and settings overlays prevent hover/status text from resizing the canvas. + Projection focus fills the viewport with collapsible floating navigation and controls. Evidence: real checkpoint diagnostics match the preserved baseline; 84 browser checks, 12 affected Python tests, motion checks, static checks, and installed-wheel diff --git a/docs/prototype-explorer.md b/docs/prototype-explorer.md index 7a7229ee..fffef491 100644 --- a/docs/prototype-explorer.md +++ b/docs/prototype-explorer.md @@ -63,8 +63,11 @@ Choose **All views** to return to the overview. Focused projection and dendrogram panels use the available viewport height as well as width. Thumbnail settings and **Map details & credits** open as overlays, leaving the -map size fixed while hover credits and loading status change. Focused projection -padding is compact to leave more room for the map. +map size fixed while hover credits and loading status change. The focused projection +fills the viewport edge to edge. **Explorer · view & class** and **Map controls** +are collapsible floating menus for navigation, selection, projection and thumbnail +settings. Related inspection panels remain below the map. Leaving projection +focus restores the regular toolbar and panel headings. The spatial map adapts its aspect ratio while preserving equal geometric scale on both axes; its raster follows display pixel density with readable screen-size labels. Dendrograms use the wider layout with readable labels and internal scrolling on diff --git a/mini_trainer/visualization/prototype_space/report.html b/mini_trainer/visualization/prototype_space/report.html index 8d622a77..9909e3a9 100644 --- a/mini_trainer/visualization/prototype_space/report.html +++ b/mini_trainer/visualization/prototype_space/report.html @@ -50,9 +50,26 @@ body.focused #matrix-panel .matrix{max-width:min(100%,calc(100dvh - 350px));margin:auto} .panel-focus{font-size:12px;float:right;margin:0 0 8px 12px} @media(max-width:850px){body.focused main{padding:8px}.controls{gap:8px;padding:8px}.grid>div{min-width:0;overflow:auto}body.focused .panel{padding:14px}} + +#workspace-controls,#projection-controls{margin:0} +#workspace-controls>summary,#projection-controls>summary{display:none} +body[data-view=projection] main{padding:0;gap:16px} +body[data-view=projection] #projection-panel.primary-view{height:100dvh;padding:0;border:0;border-radius:0;flex:0 0 100%} +body[data-view=projection] #projection-stage{min-height:0!important} +body[data-view=projection] #workspace-controls{position:fixed;top:8px;left:8px;z-index:20} +body[data-view=projection] #projection-controls{position:absolute;top:8px;right:8px;z-index:10} +body[data-view=projection] #workspace-controls>summary,body[data-view=projection] #projection-controls>summary{display:block;list-style:revert;padding:6px 10px;background:#fffffff2;border:1px solid var(--line);border-radius:6px;width:max-content;font-size:13px;box-shadow:0 2px 8px #0001} +body[data-view=projection] #projection-controls>summary{margin-left:auto} +body[data-view=projection] .controls,body[data-view=projection] .projection-controls-content{position:absolute;top:calc(100% + 6px);width:min(560px,calc(100vw - 16px));max-height:calc(100dvh - 65px);overflow:auto;background:#fffffff5;border:1px solid var(--line);border-radius:8px;padding:12px;box-shadow:0 3px 12px #0002} +body[data-view=projection] .controls{left:0} +body[data-view=projection] .projection-controls-content{right:0} +body[data-view=projection] #thumbnail-options>div{position:static;max-height:none;box-shadow:none} +body[data-view=projection] .projection-controls-content>p.muted{display:none} +body[data-view=projection] .label-settings>div{position:static;width:auto;max-width:100%;box-shadow:none} +body[data-view=projection] #score-note{order:0;padding:12px;margin:0}
mini_trainer · empirical exploration

Prototype observatory

Move between the global tree, pairwise structure and a class’s local neighbourhood. The existing mini_trainer distance is the reference; the pre-CDF z-score exposes detail that can be hidden by probability rounding.

-
+
Explorer · view & class
Save HTML report @@ -62,7 +79,7 @@
Class names
-
+

@@ -71,10 +88,10 @@

Distribution of pairwise z-scores

The actual transform used by mini_trainer, before the Normal CDF. Self-pairs excluded. No fitted distribution or assumed organization.

Neighbourhood signature

Similarity to the 1st, 5th, 12th and 32nd neighbour in the full space. High values across ranks indicate several strongly aligned directions.

Teal: selected class. Grey: per-rank median across classes. Rank points are not an embedding projection.

-

Prototype space · spatial projection

+
Map controls

Prototype space · spatial projection

Compare linear PCA with angular t-SNE when available. Scroll to zoom, drag to pan, click a point or thumbnail to inspect its class. Each case is fitted separately.

Thumbnail settings
-
+
Map details & credits

Hover a point for its class ID.

Red: selected class · teal: its neighbours in the original space · orange rings: nearby in this plane only · grey: other classes. Links follow the original-space ranking. Projected gaps and apparent overlaps can differ from the full embedding geometry.

Browse by class image

Photo labels for the selected class and its nearest prototype directions. The prototype marks the learned target direction for the class; a GBIF class example provides its visual label. Credits and exact class IDs stay attached.

@@ -261,6 +278,7 @@ document.body.dataset.view=view;document.body.classList.toggle('focused',view!=='all'); $('workspace-view').value=view; $('thumbnail-options').open=view==='all'; + $('workspace-controls').open=view!=='projection';$('projection-controls').open=view!=='projection'; for(const panel of document.querySelectorAll('main .panel')){ panel.hidden=view!=='all'&&!viewGroups[view].includes(panel.id); panel.classList.toggle('primary-view',view!=='all'&&panel.id===viewGroups[view][0]); From 3907f305c253b0929d442f21a50825052d93425b Mon Sep 17 00:00:00 2001 From: asgersvenning Date: Fri, 11 Sep 2026 11:41:40 +0200 Subject: [PATCH 25/34] docs: define next prototype explorer implementation goals --- docs/prototype-browser-implementation.md | 4 + docs/prototype-explorer-implementation.md | 114 ++++++++++++++++++++++ docs/prototype-explorer-roadmap.md | 35 ++++--- 3 files changed, 140 insertions(+), 13 deletions(-) create mode 100644 docs/prototype-explorer-implementation.md diff --git a/docs/prototype-browser-implementation.md b/docs/prototype-browser-implementation.md index ad4f8ebb..5654dea2 100644 --- a/docs/prototype-browser-implementation.md +++ b/docs/prototype-browser-implementation.md @@ -4,6 +4,10 @@ Status: ready for goal setting; implementation has not started. This plan define execution and acceptance for the standalone direction in the [compact roadmap](prototype-explorer-roadmap.md). It does not create an active goal. +The [unified execution plan](prototype-explorer-implementation.md) schedules +milestones 1–3 as a bounded feasibility goal before the inference UI. The goal +below remains the full seven-milestone destination, not the next single increment. + ## Goal text > Build and validate a standalone prototype explorer that loads a mini_trainer diff --git a/docs/prototype-explorer-implementation.md b/docs/prototype-explorer-implementation.md new file mode 100644 index 00000000..c0a571ac --- /dev/null +++ b/docs/prototype-explorer-implementation.md @@ -0,0 +1,114 @@ +# Prototype explorer: next implementation increments + +Status: proposed execution plan, ready for bounded goal setting. No goal is active +and no implementation below is claimed complete. The [roadmap](prototype-explorer-roadmap.md) +owns priorities; the [standalone plan](prototype-browser-implementation.md) owns +the complete browser inference and distribution contract. + +## Starting point + +Baseline: `feature/prototype-space`, through `05aafee`. Preserve the full-pane map, +floating controls, names, score semantics, thumbnail continuity and existing +Python entry points. Continue in `/home/asger/mini_trainer/.worktrees/prototype-space`; +verify its status before work. Use assigned worktrees for any authorized parallel +implementation; integration remains a separate reviewed step. + +Use the epoch-26 checkpoint and hash recorded in the roadmap. Epoch 4 is the +longitudinal comparison. Existing effective-weight extraction, class ordering, +distance and log-tail functions remain the numerical baseline. Synthetic cases +exercise edge conditions without specifying how the real prototypes should pack. + +## 1. Reliable repeated exploration — next implementation goal + +> Make the current explorer resumable and reusable: restore compatible viewer +> state, reuse completed analysis, expose preparation progress, and cancel or +> replace preparation without publishing stale results. Validate with the real +> epoch-26 case and preserve existing numerical outputs and interaction behavior. + +Deliver in three reviewable slices: + +| Slice | Implementation boundary | Acceptance | +| --- | --- | --- | +| 1a · Viewer state | Versioned state serializer in the packaged viewer. Save case/model identity, selected class ID, focused view, plane and pan/zoom, subtree, colour range, name preferences and thumbnail settings. Provide explicit reset and JSON export/import. | Reload restores a compatible view without refitting or changing class order. Different models and malformed/older state fall back clearly. Storage failure does not prevent use. Do not persist transient force positions or local image files. | +| 1b · Analysis reuse | Extend `launch.py` and `explore.py` with an explicit persistent cache location and a manifest keyed by checkpoint content hash, extraction/analysis version, numerical settings and projection method/seed. Store completed numerical artifacts separately from viewer assets. Publish atomically; bound cache size and expose clearing. | A second open skips analysis; changed weights/settings invalidate the entry; partial or corrupt entries rebuild safely. Viewer-only changes render current assets over cached data. Cache hit/miss and provenance are visible. | +| 1c · Preparation lifecycle | Extend the launcher's existing child-process build into an owned cancellable job. Report named stages and elapsed time, not invented percentage progress. Reuse its subprocess boundary for non-interruptible library stages. Use job identities for publication. | Cancel, replacement, failure and shutdown release owned resources. A cancelled/older job never replaces a newer report or cache entry. The last completed report remains usable. | + +Before changing behavior, record cold preparation time, reopening time, peak RSS, +HTML size, browser load time and interaction frame timing on the same machine. +Repeat after implementation with identical numerical options; distinguish disk, +analysis and browser costs. A cache hit must execute no analysis stages. Do not +turn environment-dependent timing into brittle regression assertions. + +Checks: extend `test_prototype_launcher.py` for invalidation, interrupted writes +and stale-job races; use `test_prototype_exploration.py` for unchanged results. +Extend the browser harness for restoration, model mismatch and storage failures. +Run affected Python tests and static checks; run the full harness if changes +extend into model loading or the validation harness as required by repository policy. +Refresh the real-data preview after each accepted slice. + +## 2. Real-model browser feasibility — next decision gate + +Execute milestones 1–3 of the standalone plan as a bounded qualification goal: +contract and fixtures, opt-in predictions-plus-embedding export, then WASM browser +parity. Inspect the actual architecture and preprocessing first. The current +prototype-only loader does not establish that a complete inference model can be +reconstructed without additional metadata. + +Deliver a versioned bundle proposal, reproducible export/qualification command, +real-image reference fixtures, and a minimal browser inference harness. Start +with identical preprocessed tensors, then qualify image decoding/preprocessing. +Measure cold load, warm latency and peak memory on a named browser/machine. +Use the existing ONNX exporter and output semantics; do not duplicate the head. + +Pass only when real checkpoint embeddings and predictions satisfy the standalone +plan's parity gates. If required metadata/images are missing or runtime operators +fail, report the concrete missing input or operation and the smallest next step. +Do not replace real-model evidence with a tiny model or silently relax tolerances. +Full inference UI, WebGPU and client-side global analysis remain later milestones. + +## 3. Local packing diagnostics — next geometry feature + +Add a selected-class packing panel linked to the existing inspector. Compute +angular cap counts and neighbour-radius curves from original-space relationships; +let the user select a radius or rank and highlight the same class set in map, +matrix and tree. Add mutual-neighbour membership and taxonomy composition using +available checkpoint hierarchy; missing names/taxonomy stay explicit. + +Start with selected-class computation rather than another dense all-pairs payload. +Record the source, clamp convention, self-exclusion and tie handling. Keep empirical +counts separate from any optional uniform-sphere reference. Chance-alignment q +remains a reference tail, not a learned-class posterior or an automatic significance +decision across many selected pairs. + +Gate: exact agreement with repository-derived relationships on epoch 26 and +algebraic fixtures (duplicates, antipodes, orthogonality and ties). Selection must +not refit the projection or restart thumbnail motion. Provide a preview where a +radius change visibly identifies the same neighbourhood across linked views. + +## 4. Anchor view — then query embedding UI + +Implement a single-anchor view with angular radius, followed by selectable bearing +and two-anchor comparison only after the first view is validated. Define bearing +degeneracy at parallel/antipodal directions and disclose non-anchor distortion. +Reuse image labels, floating controls and culling rather than creating a new viewer. + +Gate: anchor radii agree with direct angular calculations; changing display bearings +does not change neighbourhood membership. This provides a geometrically defined +view for query embeddings once browser inference is qualified. + +Then execute standalone milestones 4–5: local image selection, actual predictions, +query-to-prototype diagnostics and fixed-transform PCA/anchor placement. Qualify +fixed-map t-SNE insertion separately. Finish milestones 6–7 for client-side global +diagnostic construction and standalone/offline distribution; inference alone does +not complete the standalone goal. + +## Research queue and reporting + +Keep slices, alternative layouts and checkpoint trajectories behind these gates. +Formalizing chance-alignment inference, multiplicity and empirical calibration is +a research task; it must not change the baseline numerical semantics implicitly. + +Each increment ends with a runnable preview, focused commit, tests and compact +real-data evidence, plus roadmap status and remaining limitations. Do not start +all increments as one implementation task. The first quoted goal above is the +recommended next goal; no token/time budget is implied. diff --git a/docs/prototype-explorer-roadmap.md b/docs/prototype-explorer-roadmap.md index e1eff318..73fc585a 100644 --- a/docs/prototype-explorer-roadmap.md +++ b/docs/prototype-explorer-roadmap.md @@ -30,8 +30,10 @@ CLI/file-picker checks passed. Current extraction supports float32 linear `Classifier` and `HierarchicalClassifier` heads; dense analysis needs several GB for the 12,632-class case. These checks do not establish full-suite/GPU coverage. -The names/probability/layout increment additionally passed 133 browser assertions, -11 affected Python tests, log-tail reference and motion checks, and static checks. +The latest viewer increment passed 150 browser assertions; the preceding +names/probability increment also passed +11 affected Python tests and log-tail reference and motion checks. Static checks +passed for the latest changes. These are scoped results, not a new full-suite audit. Current real-data reference: `tmp/best_global-lepi-production-w32-1_epoch26.pt` (training progress reported as 26/30 epochs), SHA-256 @@ -76,17 +78,24 @@ This is planned work; no browser model inference is implemented or validated yet | Order / status | Increment | Done when | | --- | --- | --- | -| 1 · Next | Reliable repeated exploration: progress/cancellation, analysis cache keyed by checkpoint and numerical configuration, saved viewer state. | Reopening avoids unnecessary analysis; cancellation leaves a usable session; loading, peak memory and frame motion are measured on the real case. | -| 2 · Planned | Local packing: angular cap counts, neighbour-radius distributions, mutual-neighbour links and taxonomy composition across scales. | Selected-class profiles and linked outliers agree with exact original-space calculations; unknown taxonomy remains explicit. | -| 3 · Planned | Anchor atlas, then two-anchor comparison: angular rings, selectable bearings and existing photo controls. | Radius matches the chosen baseline angular convention; bearing degeneracies and non-anchor distortion are disclosed and tested. | -| 4 · Research | Great-circle/great-sphere slices with winners, competing classes and margins. | Slice scores match direct high-dimensional evaluation; geometric regions are distinguished from the full classifier decision rule and global cell volumes. | -| 5 · Research | Compare further planar/spherical layouts; add a globe only if useful. | Near/far angular error, neighbour retention, seed stability, fit cost and display distortion justify a new option. | -| 6 · Later | Checkpoint trajectories, then held-out embedding overlays and observed confusion. | Comparisons align class IDs and distinguish rotation/layout changes from changes in relationships; empirical claims identify their sample data. | - -Take one bounded increment at a time. Qualify standalone inference before committing -to its runtime design. The next geometry feature is local packing -profiles followed by the anchor atlas; reliability work supports both. Integration -into the target branch requires review and validation of the combined result. +| 1 · Next | Reliable repeated exploration: saved viewer state, versioned analysis cache, preparation progress/cancellation. | Compatible reopening restores state and skips analysis; cancelled/stale jobs cannot replace valid results; real-case load and memory costs are recorded. | +| 2 · Qualification | Real-model browser inference: standalone milestones 1–3. | Exact preprocessing/embedding/output contracts are identified; epoch-26 predictions and embeddings pass real-image WASM browser parity with latency/memory evidence. | +| 3 · Planned | Local packing: angular cap counts, neighbour-radius curves, mutual neighbours and taxonomy composition. | Radius/rank selection links the same original-space class set across views; calculations match exact references. | +| 4 · Planned | Single-anchor view, then bearings and two-anchor comparison. | Angular radii match direct calculation; degeneracies and non-anchor distortion are disclosed; existing image controls work. | +| 5 · Depends on 2 | Query inference UI and embedding placement: standalone milestones 4–5. | Actual predictions, query geometry and fixed-transform PCA/anchor placement are linked; nonlinear insertion is separately qualified. | +| 6 · Depends on 2 and 5 | Client-side global diagnostics and standalone distribution: milestones 6–7. | The real bundle generates global views locally and works offline without Python/PyTorch/CUDA; numerical and resource gates pass. | +| Later · Research | Great-sphere slices, alternative layouts, checkpoint trajectories and empirical calibration. | Each proposal earns implementation through a bounded experiment and original-space validation. | + +The [next-increment implementation plan](prototype-explorer-implementation.md) +specifies code boundaries, deliverables, tests and the recommended next goal. +This table is the unified execution order; the standalone plan provides detailed +acceptance gates, not a second competing priority list. Geometry increments 3–4 +can proceed if browser qualification needs an external input, with the blocker +recorded. Do not treat such progress as completion of browser qualification. + +Take one bounded increment at a time. Preserve the full-pane UX as the baseline; +further presentation changes should support a diagnostic or demonstrated issue. +Integration into the target branch requires review and combined validation. ## Rules for research and prioritization From 87e40f2b721304c84623ed286f541cfade83cd62 Mon Sep 17 00:00:00 2001 From: asgersvenning Date: Fri, 11 Sep 2026 12:14:46 +0200 Subject: [PATCH 26/34] feat: make prototype exploration reusable and responsive --- dev/prototype_space/check_browser.mjs | 23 ++ dev/prototype_space/check_layout.mjs | 13 + dev/prototype_space/check_session_browser.mjs | 46 ++++ dev/prototype_space/measure_interaction.mjs | 57 +++++ dev/prototype_space/reuse-performance.md | 87 +++++++ dev/wheel_smoke.py | 2 +- docs/prototype-explorer-implementation.md | 11 +- docs/prototype-explorer-roadmap.md | 11 +- docs/prototype-explorer.md | 26 ++ .../visualization/prototype_space/cache.py | 84 +++++++ .../visualization/prototype_space/explore.py | 19 +- .../visualization/prototype_space/launch.py | 237 +++++++++++++++--- .../prototype_space/launcher.html | 7 +- .../visualization/prototype_space/photos.js | 31 ++- .../prototype_space/projection.js | 13 +- .../visualization/prototype_space/report.html | 6 +- .../visualization/prototype_space/state.js | 61 +++++ .../prototype_space/thumbnails.js | 3 +- tests/utils/test_prototype_launcher.py | 156 +++++++++++- 19 files changed, 822 insertions(+), 71 deletions(-) create mode 100644 dev/prototype_space/check_session_browser.mjs create mode 100644 dev/prototype_space/measure_interaction.mjs create mode 100644 dev/prototype_space/reuse-performance.md create mode 100644 mini_trainer/visualization/prototype_space/cache.py create mode 100644 mini_trainer/visualization/prototype_space/state.js diff --git a/dev/prototype_space/check_browser.mjs b/dev/prototype_space/check_browser.mjs index bb7288ae..70b28606 100644 --- a/dev/prototype_space/check_browser.mjs +++ b/dev/prototype_space/check_browser.mjs @@ -291,6 +291,29 @@ try{ check(geometry()===stableGeometry,'thumbnail settings overlay preserves map geometry'); $('thumbnail-options').open=false;$('projection-details').open=false;$('map-photo-credit').innerHTML=savedCredit; check(rect.height>innerHeight*.75,'focused map uses over three quarters of landscape height'); + const saved=captureViewerState(); + selectClass((selected+1)%data.names.length);applyViewerState(saved); + await new Promise(r=>setTimeout(r,120)); + check(data.names[selected]===saved.selected&&projectionView.scale===saved.view.scale,'saved state restores selection and projection'); + let rejected=false;try{applyViewerState({...saved,identity:'other-model'});}catch{rejected=true;} + check(rejected,'state from another model is rejected'); + rejected=false;try{applyViewerState({...saved,view:{x:0,y:0,scale:-1}});}catch{rejected=true;} + check(rejected,'malformed saved geometry is rejected'); + saveViewerState();check(JSON.parse(localStorage.getItem('prototype-view:'+viewerIdentity())).version===1,'state persists in browser storage'); + const storageWrite=Storage.prototype.setItem;Storage.prototype.setItem=()=>{throw Error('quota');};saveViewerState();Storage.prototype.setItem=storageWrite; + check($('viewer-state-status').textContent.includes('unavailable'),'storage failure leaves viewer usable'); + const metadataFetch=window.fetch;let requests=0,active=0,peakMetadata=0; + window.fetch=async url=>{requests++;active++;peakMetadata=Math.max(peakMetadata,active);await new Promise(r=>setTimeout(r,40));active--;return {ok:true,json:async()=>({class_id:String(url).split('/').pop(),photos:[]})};}; + const obsolete=new AbortController(),current=new AbortController(); + const first=referenceAlbum('fixture-shared',obsolete.signal);obsolete.abort(); + const second=referenceAlbum('fixture-shared',current.signal);await Promise.all([first,second]); + check(requests===1,'navigation reuses in-flight class metadata'); + await Promise.all(Array.from({length:8},(_,i)=>referenceAlbum('fixture-bound-'+i,current.signal))); + check(peakMetadata<=4,'metadata requests are globally bounded'); + const cachedRequests=requests;await referenceAlbum('fixture-shared',current.signal); + check(requests===cachedRequests,'resolved metadata is reused without fetching');window.fetch=metadataFetch; + + return checks; })()`,returnByValue:true,awaitPromise:true}); if(enhanced.exceptionDetails)throw Error(JSON.stringify(enhanced.exceptionDetails)); diff --git a/dev/prototype_space/check_layout.mjs b/dev/prototype_space/check_layout.mjs index b91069cd..63d14b03 100644 --- a/dev/prototype_space/check_layout.mjs +++ b/dev/prototype_space/check_layout.mjs @@ -106,3 +106,16 @@ for(let z=-56;z<56;z+=.1)assert.ok(referenceLogTail(z+.1)<=referenceLogTail(z),' assert.notEqual(probabilityText(referenceLogTail(56)/Math.LN10),'0','extreme probability remains readable without underflow'); assert.ok(probabilityText(Math.log10(.999999)).startsWith('1 −'),'near-one values show their complement'); console.log(JSON.stringify({logTailReferenceChecks:probabilityReference.length})); + +const photoSource=readFileSync(new URL('./photos.js',import.meta.url),'utf8').split('function photoText')[0]; +const completed=[];let metadataCalls=0; +const albumLoader=Function('fetch',photoSource+'return {referenceAlbum};')(async url=>{ + metadataCalls++;await new Promise(resolve=>completed.push(resolve)); + return {ok:true,json:async()=>({class_id:url.split('/').pop(),photos:[]})}; +}); +const activeSignal=new AbortController(),obsoleteSignal=new AbortController(); +const inFlight=Array.from({length:4},(_,i)=>albumLoader.referenceAlbum(String(i),activeSignal.signal)); +const queuedMetadata=albumLoader.referenceAlbum('obsolete',obsoleteSignal.signal).catch(error=>error.name); +obsoleteSignal.abort();completed.splice(0).forEach(resolve=>resolve());await Promise.all(inFlight); +assert.equal(await queuedMetadata,'AbortError');assert.equal(metadataCalls,4,'obsolete queued metadata is never requested'); +console.log(JSON.stringify({queuedMetadataCancellationChecks:2})); diff --git a/dev/prototype_space/check_session_browser.mjs b/dev/prototype_space/check_session_browser.mjs new file mode 100644 index 00000000..345ad805 --- /dev/null +++ b/dev/prototype_space/check_session_browser.mjs @@ -0,0 +1,46 @@ +// Optional browser smoke check. Set CHROMIUM_BIN; pass a report URL/path and output directory. +import {spawn} from 'node:child_process'; +import {writeFileSync,mkdtempSync,rmSync,mkdirSync,existsSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {pathToFileURL} from 'node:url'; +import {resolve,join} from 'node:path'; +const executable=process.env.CHROMIUM_BIN; +if(!executable||!existsSync(executable)||!process.argv[2])throw Error('Set CHROMIUM_BIN and pass report URL/path [output directory]'); +const output=resolve(process.argv[3]||'tmp/prototype-browser-check');mkdirSync(output,{recursive:true}); +const profile=mkdtempSync(tmpdir()+'/prototype-browser-'); +const chrome=spawn(executable,['--headless','--no-sandbox','--disable-gpu','--disable-dev-shm-usage','--no-first-run','--no-default-browser-check','--disable-background-networking','--disable-component-update','--disable-extensions','--remote-debugging-pipe','--user-data-dir='+profile],{stdio:['ignore','ignore','ignore','pipe','pipe']}); +let sequence=0,buffer=Buffer.alloc(0);const pending=new Map();const errors=[]; +chrome.stdio[4].on('data',chunk=>{buffer=Buffer.concat([buffer,chunk]);let end;while((end=buffer.indexOf(0))!==-1){const msg=JSON.parse(buffer.subarray(0,end));buffer=buffer.subarray(end+1);if(msg.id){const cb=pending.get(msg.id);if(cb){pending.delete(msg.id);msg.error?cb.reject(Error(JSON.stringify(msg.error))):cb.resolve(msg.result);}}else if(msg.method==='Runtime.exceptionThrown')errors.push(msg.params.exceptionDetails);}}); +function send(method,params={},sessionId){const id=++sequence;return new Promise((resolve,reject)=>{pending.set(id,{resolve,reject});chrome.stdio[3].write(JSON.stringify({id,method,params,...(sessionId?{sessionId}:{})})+'\0');});} +const deadline=setTimeout(()=>{console.error('Browser smoke timed out');chrome.kill('SIGTERM');process.exitCode=1;},60000); +try{ + const {targetId}=await send('Target.createTarget',{url:'about:blank'});const {sessionId}=await send('Target.attachToTarget',{targetId,flatten:true}); + const call=(m,p)=>send(m,p,sessionId);await call('Page.enable');await call('Runtime.enable');await call('Emulation.setDeviceMetricsOverride',{width:1440,height:1100,deviceScaleFactor:1,mobile:false}); + await call('Page.navigate',{url:process.argv[2]}); + let value; + for(let i=0;i<100;i++){ + const result=await call('Runtime.evaluate',{expression:`typeof data!=='undefined'&&document.querySelectorAll('#neighbours tr').length?{classes:data.names.length,checkpoint:data.metadata.checkpoint,sha:data.metadata.checkpoint_sha256,points:projectionHits.length,projection:document.getElementById('projection-plane').value}:null`,returnByValue:true}); + value=result.result.value;if(value)break;await new Promise(r=>setTimeout(r,100)); + } + if(!value||value.classes!==12632||value.points!==12632||!value.checkpoint.includes('epoch26')||value.projection!=='Angular t-SNE'||errors.length)throw Error(JSON.stringify({value,errors})); + const persisted=await call('Runtime.evaluate',{expression:`(async()=>{ + for(let i=0;i<100&&!viewerServerToken;i++)await new Promise(r=>setTimeout(r,50)); + if(!viewerServerToken)throw Error('Server state API not available'); + setWorkspaceView('projection');selectClass(1);await new Promise(r=>setTimeout(r,100)); + projectionView.scale*=1.25;drawProjection();saveViewerState();await viewerStateWrites; + const remote=await (await fetch('/api/view-state?key='+encodeURIComponent(viewerDocumentKey))).json(); + if(remote.state.view.selected!==data.names[1])throw Error('State not persisted by local server'); + localStorage.clear();restoringViewerState=true; + })()`,awaitPromise:true}); + if(persisted.exceptionDetails)throw Error(JSON.stringify(persisted.exceptionDetails)); + await call('Page.reload'); + let restored=false; + for(let i=0;i<100;i++){ + const result=await call('Runtime.evaluate',{expression:`typeof viewerServerToken!=='undefined'&&viewerServerToken&&selected===1&&document.body.dataset.view==='projection'`,returnByValue:true}); + if(result.result.value){restored=true;break;}await new Promise(r=>setTimeout(r,100)); + } + if(!restored||errors.length)throw Error('Server-backed reload did not restore state: '+JSON.stringify(errors)); + console.log('Server-backed state restored after clearing browser storage'); + const shot=await call('Page.captureScreenshot',{format:'png'});writeFileSync(join(output,'epoch26.png'),Buffer.from(shot.data,'base64')); + console.log(JSON.stringify({value,javascriptErrors:errors.length}));await send('Browser.close'); +}catch(e){console.error(e);process.exitCode=1;chrome.kill('SIGTERM');}finally{clearTimeout(deadline);setTimeout(()=>rmSync(profile,{recursive:true,force:true}),1000);} diff --git a/dev/prototype_space/measure_interaction.mjs b/dev/prototype_space/measure_interaction.mjs new file mode 100644 index 00000000..a2aee0cc --- /dev/null +++ b/dev/prototype_space/measure_interaction.mjs @@ -0,0 +1,57 @@ +// Optional browser smoke check. Set CHROMIUM_BIN; pass a report URL/path and output directory. +import {spawn} from 'node:child_process'; +import {writeFileSync,mkdtempSync,rmSync,mkdirSync,existsSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {pathToFileURL} from 'node:url'; +import {resolve,join} from 'node:path'; +const executable=process.env.CHROMIUM_BIN; +if(!executable||!existsSync(executable)||!process.argv[2])throw Error('Set CHROMIUM_BIN and pass report URL/path [output directory]'); +const output=resolve(process.argv[3]||'tmp/prototype-browser-check');mkdirSync(output,{recursive:true}); +const profile=mkdtempSync(tmpdir()+'/prototype-browser-'); +const chrome=spawn(executable,['--headless','--no-sandbox','--disable-gpu','--disable-dev-shm-usage','--no-first-run','--no-default-browser-check','--disable-background-networking','--disable-component-update','--disable-extensions','--remote-debugging-pipe','--user-data-dir='+profile],{stdio:['ignore','ignore','ignore','pipe','pipe']}); +let sequence=0,buffer=Buffer.alloc(0);const pending=new Map();const errors=[]; +chrome.stdio[4].on('data',chunk=>{buffer=Buffer.concat([buffer,chunk]);let end;while((end=buffer.indexOf(0))!==-1){const msg=JSON.parse(buffer.subarray(0,end));buffer=buffer.subarray(end+1);if(msg.id){const cb=pending.get(msg.id);if(cb){pending.delete(msg.id);msg.error?cb.reject(Error(JSON.stringify(msg.error))):cb.resolve(msg.result);}}else if(msg.method==='Runtime.exceptionThrown')errors.push(msg.params.exceptionDetails);}}); +function send(method,params={},sessionId){const id=++sequence;return new Promise((resolve,reject)=>{pending.set(id,{resolve,reject});chrome.stdio[3].write(JSON.stringify({id,method,params,...(sessionId?{sessionId}:{})})+'\0');});} +const deadline=setTimeout(()=>{console.error('Browser smoke timed out');chrome.kill('SIGTERM');process.exitCode=1;},60000); +try{ + const {targetId}=await send('Target.createTarget',{url:'about:blank'});const {sessionId}=await send('Target.attachToTarget',{targetId,flatten:true}); + const call=(m,p)=>send(m,p,sessionId);await call('Page.enable');await call('Runtime.enable');await call('Emulation.setDeviceMetricsOverride',{width:1440,height:1100,deviceScaleFactor:1,mobile:false}); + await call('Page.navigate',{url:process.argv[2]}); + let value; + for(let i=0;i<100;i++){ + const result=await call('Runtime.evaluate',{expression:`typeof data!=='undefined'&&document.querySelectorAll('#neighbours tr').length?{classes:data.names.length,checkpoint:data.metadata.checkpoint,sha:data.metadata.checkpoint_sha256,points:projectionHits.length,projection:document.getElementById('projection-plane').value}:null`,returnByValue:true}); + value=result.result.value;if(value)break;await new Promise(r=>setTimeout(r,100)); + } + if(!value||value.classes!==12632||value.points!==12632||!value.checkpoint.includes('epoch26')||value.projection!=='Angular t-SNE'||errors.length)throw Error(JSON.stringify({value,errors})); + await call('Runtime.evaluate',{expression:`setWorkspaceView('projection')`});await new Promise(r=>setTimeout(r,300)); + const metrics=await call('Runtime.evaluate',{expression:`(async()=>{ + const samples=[],frameTimes=[];for(let frame=0;frame<20;frame++){ + await new Promise(requestAnimationFrame);projectionDrag={point:[600,300],view:{...projectionView},moved:true}; + const r=projectionCanvas.getBoundingClientRect(),start=performance.now(); + for(let i=0;i<30;i++)projectionCanvas.onpointermove({clientX:r.left+r.width/2+i,clientY:r.top+r.height/2}); + samples.push(performance.now()-start);projectionDrag=null;await new Promise(requestAnimationFrame);frameTimes.push(performance.now()-start); + } + projectionDrag=null;fitProjection(data.names.map((_,i)=>i));drawProjection(); + const originalFetch=window.fetch,pixel='data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + let calls=0,aborts=0,first=null; + window.fetch=async(url,options={})=>{ + if(url==='/api/health')return {ok:true,json:async()=>({photo_api:true})}; + calls++;await new Promise((resolve,reject)=>{const timer=setTimeout(resolve,500);options.signal?.addEventListener('abort',()=>{clearTimeout(timer);aborts++;reject(new DOMException('Aborted','AbortError'));},{once:true});}); + return {ok:true,json:async()=>({class_id:String(url).split('/').pop(),display_name:'Controlled latency fixture',photos:[{image_path:pixel,creator:'Fixture',license:'Fixture',source:'https://example.org',occurrence_id:'1'}]})}; + }; + photoAlbums.clear();mapThumbImages.clear();mapThumbFailures.clear();$('map-photos').checked=true;$('map-photo-density').value='20';$('map-photo-push').checked=false; + const loadStart=performance.now();drawProjection(); + const observe=setInterval(()=>{if(first===null&&mapThumbLayout.length)first=performance.now()-loadStart;},10); + for(let i=0;i<3;i++){await new Promise(r=>setTimeout(r,250));projectionView.x+=.1;drawProjection();} + let finished=false; + for(let i=0;i<160;i++){await new Promise(r=>setTimeout(r,50));const counts=$('map-photo-status').textContent.match(/([0-9]+) images loaded · ([0-9]+) visible slots/);if(counts&&Number(counts[1])===Number(counts[2])&&Number(counts[1])>0){finished=true;break;}} + clearInterval(observe);const loading={firstVisibleMs:first,elapsedMs:performance.now()-loadStart,visible:mapThumbLayout.length,calls,aborts,finished,fixture:'500 ms metadata; tiny local image; 3 navigation updates'}; + $('map-photos').checked=false;drawProjection();window.fetch=originalFetch; + samples.sort((a,b)=>a-b);return {loading,frameTimes,samples,median:samples[10],p95:samples[Math.ceil(samples.length*.95)-1],classes:data.names.length,browser:navigator.userAgent}; +})()`,returnByValue:true,awaitPromise:true}); + if(metrics.exceptionDetails)throw Error(JSON.stringify(metrics.exceptionDetails)); + writeFileSync(join(output,'performance.json'),JSON.stringify(metrics.result.value,null,2)); + console.log(JSON.stringify(metrics.result.value)); + const shot=await call('Page.captureScreenshot',{format:'png'});writeFileSync(join(output,'epoch26.png'),Buffer.from(shot.data,'base64')); + console.log(JSON.stringify({value,javascriptErrors:errors.length}));await send('Browser.close'); +}catch(e){console.error(e);process.exitCode=1;chrome.kill('SIGTERM');}finally{clearTimeout(deadline);setTimeout(()=>rmSync(profile,{recursive:true,force:true}),1000);} diff --git a/dev/prototype_space/reuse-performance.md b/dev/prototype_space/reuse-performance.md new file mode 100644 index 00000000..7fd1b30d --- /dev/null +++ b/dev/prototype_space/reuse-performance.md @@ -0,0 +1,87 @@ +# Reuse and responsiveness qualification + +2026-09-11, epoch-26 checkpoint recorded in the explorer roadmap. Baseline viewer +assets: `3907f30`. Host: Intel i7-12800H, WSL2 Linux 5.15.167.4, Python 3.13.7, +torch 2.12.0+cu130, NumPy 2.4.6, headless Chromium 151. CPU analysis used four +threads; no GPU-correctness claim is made. These are local measurements, not +universal performance guarantees. + +## Preparation and numerical preservation + +| Run | Wall time | Peak RSS | +| --- | ---: | ---: | +| Uncached baseline | 82.02 s | 5,424,156 KiB | +| First cache-populating analysis | 88.59 s | 5,428,248 KiB | +| Cache reopen | 3.98 s | 1,080,716 KiB | + +The independent cold runs have identical stored distance/log-tail arrays, class +ordering, tree, neighbourhoods, PCA and t-SNE coordinates. Differences are source +provenance and the t-SNE KL diagnostic scalar (1.9826414585 versus 1.9826412201). +Cached/reopened numerical JSON is byte-identical, SHA-256 +`a474f491471b16ccb3197831a23a1dff7c17726eb8a0cc20644197c29d860b7d`. +The cache deliberately retains the original analysis provenance. + +Reproduce with `/usr/bin/time -v`, using distinct output directories for the cold +and warm runs, the same cache directory and these environment settings: + +```bash +export PYTHONPATH="$PWD" +export MPLCONFIGDIR=/tmp/mini-trainer-prototype-mpl +export OMP_NUM_THREADS=4 OPENBLAS_NUM_THREADS=4 +/usr/bin/time -v .venv/bin/python -m mini_trainer.visualization.prototype_space \ + /absolute/path/to/best_global-lepi-production-w32-1_epoch26.pt \ + --export --cache-dir tmp/qualification-cache --output tmp/qualification-cold +# Repeat with --output tmp/qualification-warm; use --no-cache for an uncached run. +``` + +## Interaction and thumbnail arrivals + +`measure_interaction.mjs` runs the same real-coordinate workload against a frozen +baseline report and the updated report. It emits raw samples and browser identity +in `performance.json`. The pan workload sends 30 pointer moves per frame over 20 +frames at 1440 × 1100. Frame times include the subsequent animation-frame callback; +handler times alone are insufficient to establish responsiveness. + +The loading workload uses a fixed 500 ms metadata delay, a tiny local image, and +three navigation updates 250 ms apart. It isolates wasted metadata requests; it +does not measure GBIF network throughput or large-image decode time. + +| Measure | Before | After | +| --- | ---: | ---: | +| Frame median | 16.9 ms | 17.3 ms | +| Frame p95 | 83.7 ms | 18.1 ms | +| First visible thumbnail | 1.484 s | 0.714 s | +| All 31 visible thumbnails | 5.064 s | 4.253 s | +| Metadata requests / navigation aborts | 43 / 12 | 31 / 0 | + +The improvement is in frame-time outliers and avoiding repeated lookups during +navigation; the median frame interval did not improve. Started metadata requests +are shared across generations/cards, with at most four active. Queued obsolete +requests are discarded. Image loading still follows the visible candidate set, +and existing appearance, motion, attribution and culling checks remain applicable. +Local matrix recolouring now transforms only its selected submatrix rather than +all classes' local matrices on every refresh. + +```bash +CHROMIUM_BIN=/path/to/chrome node dev/prototype_space/measure_interaction.mjs \ + file:///absolute/path/to/explorer.html tmp/interaction-measurement +CHROMIUM_BIN=/path/to/chrome node dev/prototype_space/check_session_browser.mjs \ + http://localhost:PORT/explorer.html tmp/session-check +``` + +Serve the epoch-26 model with the launcher for the session check. That check saves +a view through the server, clears browser storage, suppresses page-hide saving, +reloads and verifies restoration. The Python session test separately verifies +reuse across new workspace instances with different tokens. + +## Validation scope + +Targeted launcher, extraction and photo tests passed (15 tests, including a targeted real-process cancellation check), plus 158 explorer +browser assertions, the real-checkpoint session check, motion/log-tail checks, +static checks and wheel validation. The broad suite was stopped at the user's +request; it is not reported as passed. Later lifecycle-only changes are checked +with launcher tests, not unrelated training suites. + +Timing artifacts live in ignored `tmp/reuse-{baseline,cold,warm}-epoch26` and +`tmp/interaction-{before,after}-complete`. Re-run on the same host with unchanged +workload before making further speed claims. Live GBIF latency remains variable. diff --git a/dev/wheel_smoke.py b/dev/wheel_smoke.py index df55f475..9b83ed78 100644 --- a/dev/wheel_smoke.py +++ b/dev/wheel_smoke.py @@ -53,7 +53,7 @@ def main(): subprocess.run([str(Path(sys.executable).parent / entry.name), "--help"], check=True, timeout=60, capture_output=True) assets = importlib.resources.files("mini_trainer.visualization.prototype_space") - for name in ("report.html", "launcher.html", "photos.js", "projection.js", "thumbnails.js"): + for name in ("report.html", "launcher.html", "photos.js", "projection.js", "thumbnails.js", "state.js"): assert assets.joinpath(name).read_text(), f"Missing packaged explorer asset: {name}" missing_explorer = subprocess.run( [str(Path(sys.executable).parent / "mt_explore"), "--no-browser"], capture_output=True, text=True, timeout=30 diff --git a/docs/prototype-explorer-implementation.md b/docs/prototype-explorer-implementation.md index c0a571ac..14a58399 100644 --- a/docs/prototype-explorer-implementation.md +++ b/docs/prototype-explorer-implementation.md @@ -1,7 +1,7 @@ # Prototype explorer: next implementation increments -Status: proposed execution plan, ready for bounded goal setting. No goal is active -and no implementation below is claimed complete. The [roadmap](prototype-explorer-roadmap.md) +Status: increment 1 implemented and qualified, including responsiveness and +thumbnail-loading improvements added to its goal. Increments 2–4 remain proposed. The [roadmap](prototype-explorer-roadmap.md) owns priorities; the [standalone plan](prototype-browser-implementation.md) owns the complete browser inference and distribution contract. @@ -18,7 +18,7 @@ longitudinal comparison. Existing effective-weight extraction, class ordering, distance and log-tail functions remain the numerical baseline. Synthetic cases exercise edge conditions without specifying how the real prototypes should pack. -## 1. Reliable repeated exploration — next implementation goal +## 1. Reliable repeated exploration — delivered > Make the current explorer resumable and reusable: restore compatible viewer > state, reuse completed analysis, expose preparation progress, and cancel or @@ -110,5 +110,6 @@ a research task; it must not change the baseline numerical semantics implicitly. Each increment ends with a runnable preview, focused commit, tests and compact real-data evidence, plus roadmap status and remaining limitations. Do not start -all increments as one implementation task. The first quoted goal above is the -recommended next goal; no token/time budget is implied. +all increments as one implementation task. Increment 2 is the +recommended next goal; no token/time budget is implied. Increment 1 evidence is +recorded in the [qualification note](../dev/prototype_space/reuse-performance.md). diff --git a/docs/prototype-explorer-roadmap.md b/docs/prototype-explorer-roadmap.md index 73fc585a..f0957676 100644 --- a/docs/prototype-explorer-roadmap.md +++ b/docs/prototype-explorer-roadmap.md @@ -23,6 +23,10 @@ development branch; reusable-package baseline `4763041`): - Display-density projection rendering and viewport-height focused panels; map details and settings overlays prevent hover/status text from resizing the canvas. Projection focus fills the viewport with collapsible floating navigation and controls. +- Saved views (browser storage, local-server persistence, JSON import/export), + completed-analysis caching, stage/elapsed progress and cancellable replacement jobs. +- Coalesced pan/zoom drawing and shared bounded thumbnail metadata lookups; + see the [reuse and responsiveness qualification](../dev/prototype_space/reuse-performance.md). Evidence: real checkpoint diagnostics match the preserved baseline; 84 browser checks, 12 affected Python tests, motion checks, static checks, and installed-wheel @@ -78,8 +82,8 @@ This is planned work; no browser model inference is implemented or validated yet | Order / status | Increment | Done when | | --- | --- | --- | -| 1 · Next | Reliable repeated exploration: saved viewer state, versioned analysis cache, preparation progress/cancellation. | Compatible reopening restores state and skips analysis; cancelled/stale jobs cannot replace valid results; real-case load and memory costs are recorded. | -| 2 · Qualification | Real-model browser inference: standalone milestones 1–3. | Exact preprocessing/embedding/output contracts are identified; epoch-26 predictions and embeddings pass real-image WASM browser parity with latency/memory evidence. | +| 1 · Delivered | Reliable repeated exploration: saved viewer state, versioned analysis cache, preparation progress/cancellation. | Compatible reopening restores state and skips analysis; cancelled/stale jobs cannot replace valid results; real-case load and memory costs are recorded. | +| 2 · Next qualification | Real-model browser inference: standalone milestones 1–3. | Exact preprocessing/embedding/output contracts are identified; epoch-26 predictions and embeddings pass real-image WASM browser parity with latency/memory evidence. | | 3 · Planned | Local packing: angular cap counts, neighbour-radius curves, mutual neighbours and taxonomy composition. | Radius/rank selection links the same original-space class set across views; calculations match exact references. | | 4 · Planned | Single-anchor view, then bearings and two-anchor comparison. | Angular radii match direct calculation; degeneracies and non-anchor distortion are disclosed; existing image controls work. | | 5 · Depends on 2 | Query inference UI and embedding placement: standalone milestones 4–5. | Actual predictions, query geometry and fixed-transform PCA/anchor placement are linked; nonlinear insertion is separately qualified. | @@ -87,7 +91,8 @@ This is planned work; no browser model inference is implemented or validated yet | Later · Research | Great-sphere slices, alternative layouts, checkpoint trajectories and empirical calibration. | Each proposal earns implementation through a bounded experiment and original-space validation. | The [next-increment implementation plan](prototype-explorer-implementation.md) -specifies code boundaries, deliverables, tests and the recommended next goal. +specifies code boundaries, deliverables and tests. Increment 1 is delivered; +the next bounded goal is real-model browser qualification (standalone milestones 1–3). This table is the unified execution order; the standalone plan provides detailed acceptance gates, not a second competing priority list. Geometry increments 3–4 can proceed if browser qualification needs an external input, with the blocker diff --git a/docs/prototype-explorer.md b/docs/prototype-explorer.md index fffef491..63b357b6 100644 --- a/docs/prototype-explorer.md +++ b/docs/prototype-explorer.md @@ -43,6 +43,32 @@ loopback on an available port. Stop it with Ctrl+C when finished. Session files are temporary; use **Save HTML report** to retain the portable numerical viewer. Photos require the local server and network access to GBIF. +Preparation reports the current stage and elapsed time. **Cancel preparation** +stops the owned analysis process; selecting replacement weights starts a new job. +Cancelled or superseded jobs cannot publish over the latest result, and the last +completed explorer remains available after cancellation or failure. + +Completed analysis is cached by checkpoint content, analysis version and numerical +options. The default cache is `$XDG_CACHE_HOME/mini-trainer/prototype-analysis` +(or `~/.cache/mini-trainer/prototype-analysis`). It retains at most 2 GiB of completed +entries. A cache hit renders current viewer assets over the saved numerical data; +the launcher reports whether analysis was reused. Corrupt/incomplete entries rebuild. + +```bash +mt_explore weights.pt --cache-dir /path/to/analysis-cache +mt_explore weights.pt --no-cache +mt_explore --clear-cache --cache-dir /path/to/analysis-cache +``` + +**Saved view** provides JSON export/import and reset. Selection, case, projection, +pan/zoom, subtree, focused view, colour settings and thumbnail/name preferences +are saved automatically. Browser storage supports offline HTML; the local launcher +also saves state in the cache's `.view-state` directory so it survives a change of +localhost port. Clearing analysis entries does not clear saved views. Imported +state must match the model/case and supported version. Storage failures leave the +viewer usable with explicit state export. Transient thumbnail forces and image +files are not part of saved state. + For SSH or an environment without a browser, use `--no-browser` and open the printed URL through your usual local port forwarding. `--port` optionally fixes the listening port. File selection reads from the browser's computer. diff --git a/mini_trainer/visualization/prototype_space/cache.py b/mini_trainer/visualization/prototype_space/cache.py new file mode 100644 index 00000000..19dac389 --- /dev/null +++ b/mini_trainer/visualization/prototype_space/cache.py @@ -0,0 +1,84 @@ +"""Content-addressed completed analysis; viewer assets are deliberately not cached.""" + +import hashlib +import json +import os +import shutil +import tempfile +from pathlib import Path + +ANALYSIS_VERSION = 1 + + +def file_hash(path): + with Path(path).open("rb") as stream: + return hashlib.file_digest(stream, "sha256").hexdigest() + + +def analysis_key(weights, *, angular, synthetic, threads): + contract = { + "version": ANALYSIS_VERSION, + "checkpoint": file_hash(weights), + "angular": angular, + "synthetic": synthetic, + "threads": threads, + } + return hashlib.sha256(json.dumps(contract, sort_keys=True).encode()).hexdigest(), contract + + +def read_analysis(root, key, contract): + entry = Path(root) / key + try: + manifest = json.loads((entry / "manifest.json").read_text()) + if manifest["contract"] != contract: + return None + result = {} + for name in ("report-data.json", "summary.json"): + raw = (entry / name).read_bytes() + if hashlib.sha256(raw).hexdigest() != manifest["files"][name]: + return None + json.loads(raw) + result[name] = raw + os.utime(entry, None) + return result + except (OSError, ValueError, KeyError, TypeError): + return None + + +def store_analysis(root, key, contract, output, *, max_bytes=2 * 1024**3): + root = Path(root) + root.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=f".pending-{os.getpid()}-", dir=root) as temporary: + pending = Path(temporary) + hashes = {} + for name in ("report-data.json", "summary.json"): + shutil.copyfile(Path(output) / name, pending / name) + hashes[name] = file_hash(pending / name) + (pending / "manifest.json").write_text(json.dumps({"contract": contract, "files": hashes})) + target = root / key + # Another process may already have published this complete key. + if target.exists() and read_analysis(root, key, contract) is None: + shutil.rmtree(target) + try: + pending.rename(target) + except OSError: + if read_analysis(root, key, contract) is None: + raise + entries = sorted((p for p in root.iterdir() if p.is_dir() and len(p.name) == 64), key=lambda p: p.stat().st_mtime, reverse=True) + total = 0 + for entry in entries: + total += sum(p.stat().st_size for p in entry.iterdir() if p.is_file()) + if total > max_bytes: + shutil.rmtree(entry) + + +def clear_analysis_cache(root): + """Remove only complete content-addressed entries owned by this cache.""" + root = Path(root) + removed = 0 + if root.exists(): + for entry in root.iterdir(): + if entry.is_dir() and len(entry.name) == 64 and all(c in "0123456789abcdef" for c in entry.name): + shutil.rmtree(entry) + removed += 1 + return removed diff --git a/mini_trainer/visualization/prototype_space/explore.py b/mini_trainer/visualization/prototype_space/explore.py index 0f0739db..ed49ad9c 100644 --- a/mini_trainer/visualization/prototype_space/explore.py +++ b/mini_trainer/visualization/prototype_space/explore.py @@ -200,6 +200,7 @@ def analyze(weight, names, groups, *, neighbours=12, seed=42, metadata=None, inc row = z[i].copy() row[i] = -np.inf profiles[i] = -np.partition(-row, np.asarray(ranks) - 1)[np.asarray(ranks) - 1] + print("Computing PCA projections and neighbourhood retention", flush=True) projections = prototype_projections(weight, near.tolist()) if include_tsne: print("Fitting angular t-SNE", flush=True) @@ -249,6 +250,7 @@ def synthetic_cases(dim, seed=42): def create_report(weights: Path, output: Path, *, include_tsne=True, synthetic=False): """Create a portable report from a supported mini_trainer weight file.""" weights, output = Path(weights), Path(output) + print("Reading effective prototype weights", flush=True) weight, names, groups, provenance = load_prototypes(weights) cases = {"Production checkpoint": analyze(weight, names, groups, metadata=provenance, include_tsne=include_tsne)} for name, sample in (synthetic_cases(weight.shape[1]) if synthetic else {}).items(): @@ -256,21 +258,28 @@ def create_report(weights: Path, output: Path, *, include_tsne=True, synthetic=F cases[name] = analyze( sample, labels, [[label] for label in labels], metadata={"seed": 42, "synthetic": True}, include_tsne=include_tsne ) + print("Writing report artifacts", flush=True) output.mkdir(parents=True, exist_ok=True) payload = json.dumps(cases, separators=(",", ":"), allow_nan=False).replace("<", "\\u003c") (output / "report-data.json").write_text(payload) + render_report(output, payload) + summary = {name: {"metadata": case["metadata"], "stats": case["stats"]} for name, case in cases.items()} + (output / "summary.json").write_text(json.dumps(summary, indent=2) + "\n") + print(f"Report: {output / 'explorer.html'}", flush=True) + + return output / "explorer.html" + + +def render_report(output, payload): + """Render current viewer assets over preserved numerical data.""" template = Path(__file__).with_name("report.html").read_text() (output / "explorer.html").write_text( template.replace("__REPORT_DATA__", payload) .replace("__PHOTO_SCRIPT__", Path(__file__).with_name("photos.js").read_text()) .replace("__PROJECTION_SCRIPT__", Path(__file__).with_name("projection.js").read_text()) .replace("__THUMBNAIL_SCRIPT__", Path(__file__).with_name("thumbnails.js").read_text()) + .replace("__STATE_SCRIPT__", Path(__file__).with_name("state.js").read_text()) ) - summary = {name: {"metadata": case["metadata"], "stats": case["stats"]} for name, case in cases.items()} - (output / "summary.json").write_text(json.dumps(summary, indent=2) + "\n") - print(f"Report: {output / 'explorer.html'}", flush=True) - - return output / "explorer.html" def main(): diff --git a/mini_trainer/visualization/prototype_space/launch.py b/mini_trainer/visualization/prototype_space/launch.py index 434b208a..bba47dd2 100644 --- a/mini_trainer/visualization/prototype_space/launch.py +++ b/mini_trainer/visualization/prototype_space/launch.py @@ -1,18 +1,22 @@ """Launch the prototype explorer with a checkpoint path or browser file picker.""" import argparse +import hashlib import importlib.util import json +import os import secrets +import shutil import subprocess import sys import tempfile import threading +import time import webbrowser from functools import partial from http.server import ThreadingHTTPServer from pathlib import Path -from urllib.parse import unquote, urlsplit +from urllib.parse import parse_qs, unquote, urlsplit def require_dependencies(angular=True): @@ -21,36 +25,126 @@ def require_dependencies(angular=True): raise RuntimeError("Prototype exploration requires: " + ", ".join(missing) + ". Install mini_trainer[explorer].") -def generate(weights, output, *, angular=True, synthetic=False, threads=4): +def generate(weights, output, *, angular=True, synthetic=False, threads=4, cache_dir=None): require_dependencies(angular) + from .cache import analysis_key, read_analysis, store_analysis + + output = Path(output) + key = contract = cached = None + if cache_dir is not None: + print("Checking analysis cache", flush=True) + key, contract = analysis_key(weights, angular=angular, synthetic=synthetic, threads=threads) + cached = read_analysis(cache_dir, key, contract) + if cached is not None: + from .explore import render_report + + output.mkdir(parents=True, exist_ok=True) + for name, raw in cached.items(): + (output / name).write_bytes(raw) + render_report(output, cached["report-data.json"].decode()) + print("Analysis cache hit; rendered current viewer", flush=True) + return output / "explorer.html" import torch from .explore import create_report torch.set_num_threads(threads) - return create_report(weights, output, include_tsne=angular, synthetic=synthetic) + result = create_report(weights, output, include_tsne=angular, synthetic=synthetic) + if cache_dir is not None: + store_analysis(cache_dir, key, contract, output) + print("Analysis cache miss; stored completed result", flush=True) + return result class Workspace: - """One local session; checkpoint bytes are never in the served directory.""" + """Owned preparation jobs; only the current job can publish a report.""" - def __init__(self, root, angular=True, synthetic=False, threads=4): + def __init__(self, root, angular=True, synthetic=False, threads=4, cache_dir=None): self.root = root + empty = root / "empty" + empty.mkdir() self.public = root / "report" - self.public.mkdir() + self.public.symlink_to(empty, target_is_directory=True) self.angular, self.synthetic, self.threads = angular, synthetic, threads + self.cache_dir = cache_dir self.token = secrets.token_urlsafe(32) - self.lock = threading.Lock() + self.lock = threading.Lock() # One upload at a time, independent of analysis. + self.guard = threading.RLock() self.process = None + self.job = 0 + self.workers = [] self.state = {"state": "idle"} self.service = None + self.ready = False + + def view_state_path(self, key): + if not key or len(key) > 512: + raise ValueError("Invalid view-state identity") + return (self.cache_dir or self.root) / ".view-state" / (hashlib.sha256(key.encode()).hexdigest() + ".json") + + def read_view_state(self, key): + path = self.view_state_path(key) + try: + return json.loads(path.read_text()) + except (OSError, ValueError): + return None + + def write_view_state(self, key, value): + path = self.view_state_path(key) + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix("." + secrets.token_hex(8)) + try: + temporary.write_text(json.dumps(value)) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + def cancel(self): + with self.guard: + self.job += 1 + process = self.process + if process is not None and process.poll() is None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + self.process = None + self.state = {"state": "cancelled", "ready": self.ready} + + def close(self): + self.cancel() + for worker in self.workers: + worker.join(timeout=10) + + def status(self): + with self.guard: + state = dict(self.state, ready=self.ready) + if state["state"] == "running": + state["elapsed"] = round(time.monotonic() - state["started"], 1) + log = self.root / f"job-{self.job}" / "generation.log" + if log.exists(): + lines = log.read_text(errors="replace").splitlines() + stages = [line for line in lines if line and not line.startswith("{")] + state["stage"] = stages[-1] if stages else "Starting analysis" + return state def start(self, weights): - self.state = {"state": "running", "file": weights.name} - worker = threading.Thread(target=self.build, args=(weights,), daemon=True) - worker.start() + self.cancel() + with self.guard: + job = self.job + self.state = {"state": "running", "file": weights.name, "started": time.monotonic(), "job": job} + worker = threading.Thread(target=self.build, args=(weights, job), daemon=True) + self.workers = [item for item in self.workers if item.is_alive()] + self.workers.append(worker) + worker.start() - def build(self, weights): + def build(self, weights, job): + directory = self.root / f"job-{job}" + directory.mkdir() + output = directory / "report" + process = None try: command = [ sys.executable, @@ -59,30 +153,54 @@ def build(self, weights): str(weights), "--export", "--output", - str(self.public), + str(output), "--threads", str(self.threads), ] + command += ["--cache-dir", str(self.cache_dir)] if self.cache_dir is not None else ["--no-cache"] if not self.angular: command.append("--pca-only") if self.synthetic: command.append("--synthetic") - with (self.root / "generation.log").open("w") as log: - self.process = subprocess.Popen(command, stdout=log, stderr=subprocess.STDOUT) - result = self.process.wait() - if result: - detail = (self.root / "generation.log").read_text()[-4000:] - self.state = {"state": "error", "error": detail} - return - data = json.loads((self.public / "report-data.json").read_text()) - self.service.allowed_ids = { - name for case in data.values() if not case["metadata"].get("synthetic") for name in case["names"] if name.isdigit() - } - self.state = {"state": "ready", "file": weights.name, "url": "/explorer.html"} + with (directory / "generation.log").open("w") as log: + with self.guard: + if job != self.job: + return + process = subprocess.Popen(command, stdout=log, stderr=subprocess.STDOUT) + self.process = process + result = process.wait() + with self.guard: + if job != self.job: + return + if result: + raise RuntimeError((directory / "generation.log").read_text()[-4000:]) + data = json.loads((output / "report-data.json").read_text()) + ids = {name for case in data.values() if not case["metadata"].get("synthetic") for name in case["names"] if name.isdigit()} + link = directory / "publish" + link.symlink_to(output, target_is_directory=True) + previous = self.public.resolve().parent + os.replace(link, self.public) + if previous.parent == self.root and previous.name.startswith("job-"): + shutil.rmtree(previous, ignore_errors=True) + self.service.allowed_ids = ids + self.ready = True + log_text = (directory / "generation.log").read_text() + cache_status = "hit" if "Analysis cache hit" in log_text else "miss" if self.cache_dir is not None else "disabled" + self.state = {"state": "ready", "file": weights.name, "url": "/explorer.html", "cache": cache_status} except Exception as error: - self.state = {"state": "error", "error": str(error)} + with self.guard: + if job == self.job: + self.state = {"state": "error", "error": str(error)} finally: - self.lock.release() + with self.guard: + if self.public.resolve() != output.resolve(): + shutil.rmtree(directory, ignore_errors=True) + if weights.parent.parent == self.root / "inputs": + shutil.rmtree(weights.parent, ignore_errors=True) + pid = getattr(process, "pid", None) + if pid is not None and self.cache_dir is not None and self.cache_dir.exists(): + for pending in self.cache_dir.glob(f".pending-{pid}-*"): + shutil.rmtree(pending, ignore_errors=True) def session_handler(workspace): @@ -103,19 +221,43 @@ def do_GET(self): if path == "/api/health": self.send_json({"photo_api": True, "model_picker": True}) return + if path == "/api/view-state": + key = parse_qs(urlsplit(self.path).query).get("key", [""])[0] + if not key or len(key) > 512: + self.send_json({"error": "Invalid view-state identity"}, 400) + else: + self.send_json({"state": workspace.read_view_state(key), "token": workspace.token}) + return if path == "/api/session": - self.send_json(workspace.state) + self.send_json(workspace.status()) return return super().do_GET() def do_POST(self): - if self.path != "/api/weights" or self.headers.get("X-Explorer-Token") != workspace.token: + if ( + self.path not in {"/api/weights", "/api/cancel", "/api/view-state"} + or self.headers.get("X-Explorer-Token") != workspace.token + ): self.send_json({"error": "Invalid local session request"}, 403) return + if self.path == "/api/view-state": + try: + length = int(self.headers.get("Content-Length", "0")) + if not 0 < length <= 65536: + raise ValueError("View state must be at most 64 KiB") + value = json.loads(self.rfile.read(length)) + workspace.write_view_state(value["key"], value["state"]) + self.send_json({"saved": True}) + except (OSError, ValueError, KeyError, TypeError) as error: + self.send_json({"error": str(error)}, 400) + return + if self.path == "/api/cancel": + workspace.cancel() + self.send_json(workspace.status()) + return if not workspace.lock.acquire(blocking=False): self.send_json({"error": "A checkpoint is already being processed"}, 409) return - handed_off = False try: size = int(self.headers.get("Content-Length", "0")) if size <= 0: @@ -126,7 +268,9 @@ def do_POST(self): raise ValueError("Invalid checkpoint filename") inputs = workspace.root / "inputs" inputs.mkdir(exist_ok=True) - weights = inputs / name + upload = inputs / secrets.token_hex(8) + upload.mkdir() + weights = upload / name self.connection.settimeout(120) with weights.open("wb") as stream: remaining = size @@ -137,14 +281,12 @@ def do_POST(self): stream.write(chunk) remaining -= len(chunk) workspace.start(weights) - handed_off = True self.send_json({"state": "running"}, 202) except (OSError, ValueError) as error: workspace.state = {"state": "error", "error": str(error)} self.send_json(workspace.state, 400) finally: - if not handed_off: - workspace.lock.release() + workspace.lock.release() return partial(SessionHandler, directory=str(workspace.public), service=workspace.service) @@ -152,6 +294,14 @@ def do_POST(self): def run(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("weights", nargs="?", type=Path, help="mini_trainer weights; omit to choose a file in the browser") + parser.add_argument( + "--cache-dir", + type=Path, + default=Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "mini-trainer" / "prototype-analysis", + help="Persistent completed-analysis cache", + ) + parser.add_argument("--clear-cache", action="store_true", help="Clear completed analysis cache entries and exit") + parser.add_argument("--no-cache", action="store_true", help="Recompute analysis without reading or writing the cache") parser.add_argument("--export", action="store_true", help="Write a portable HTML report and exit") parser.add_argument("--output", type=Path, help="Export directory (default: -explorer)") parser.add_argument("--pca-only", action="store_true", help="Skip the slower angular t-SNE projection") @@ -160,6 +310,11 @@ def run(): parser.add_argument("--port", type=int, default=0, help="Local server port (default: choose an available port)") parser.add_argument("--no-browser", action="store_true", help="Print the local URL without opening a browser") args = parser.parse_args() + if args.clear_cache: + from .cache import clear_analysis_cache + + print(f"Removed {clear_analysis_cache(args.cache_dir)} analysis cache entries") + return if args.threads < 1: parser.error("--threads must be positive") if args.weights is not None and not args.weights.is_file(): @@ -174,7 +329,14 @@ def run(): parser.error(str(error)) if args.export: output = args.output or Path(args.weights.stem + "-explorer") - generate(args.weights, output, angular=not args.pca_only, synthetic=args.synthetic, threads=args.threads) + generate( + args.weights, + output, + angular=not args.pca_only, + synthetic=args.synthetic, + threads=args.threads, + cache_dir=None if args.no_cache else args.cache_dir, + ) return from diskcache import Cache @@ -184,14 +346,13 @@ def run(): from .serve import PhotoService with tempfile.TemporaryDirectory(prefix="mini-trainer-explorer-") as temporary: - workspace = Workspace(Path(temporary), not args.pca_only, args.synthetic, args.threads) + workspace = Workspace(Path(temporary), not args.pca_only, args.synthetic, args.threads, None if args.no_cache else args.cache_dir) with Cache(str(workspace.root / "photos"), size_limit=256 * 1024 * 1024) as cache: previous_cache = gbif._CACHE gbif._CACHE = cache workspace.service = PhotoService([], cache) server = ThreadingHTTPServer(("127.0.0.1", args.port), session_handler(workspace)) if args.weights: - workspace.lock.acquire() workspace.start(args.weights.resolve()) url = f"http://localhost:{server.server_port}/" print(f"Prototype explorer: {url}", flush=True) @@ -203,7 +364,5 @@ def run(): pass finally: server.server_close() - if workspace.process is not None and workspace.process.poll() is None: - workspace.process.terminate() - workspace.process.wait() + workspace.close() gbif._CACHE = previous_cache diff --git a/mini_trainer/visualization/prototype_space/launcher.html b/mini_trainer/visualization/prototype_space/launcher.html index 4868323a..11e7dcad 100644 --- a/mini_trainer/visualization/prototype_space/launcher.html +++ b/mini_trainer/visualization/prototype_space/launcher.html @@ -4,11 +4,12 @@

Explore model prototypes

Choose a mini_trainer weight file. The explorer reads the classifier weights and class labels, then prepares the distance matrix, dendrogram and spatial projections.

Analysis runs locally and can take several minutes for large models. Optional GBIF images are requested only when you enable them in the viewer.

-
+

Choose a checkpoint to begin.

diff --git a/mini_trainer/visualization/prototype_space/photos.js b/mini_trainer/visualization/prototype_space/photos.js index 4b484c46..af443841 100644 --- a/mini_trainer/visualization/prototype_space/photos.js +++ b/mini_trainer/visualization/prototype_space/photos.js @@ -5,6 +5,29 @@ const photoAlbums = new Map(); const photoChoices = new Map(); const photoNumber = id => /^\d+$/.test(id); +// Share metadata between map generations and the neighbourhood cards. Navigation +// discards queued work; up to four started lookups finish into the session cache. +const albumRequests=new Map(),albumQueue=[]; +let activeAlbumRequests=0; +function referenceAlbum(id,signal){ + if(photoAlbums.has(id))return Promise.resolve(photoAlbums.get(id)); + const existing=albumRequests.get(id);if(existing){existing.signals.push(signal);return existing.promise;} + const job={id,signals:[signal]};job.promise=new Promise((resolve,reject)=>{job.resolve=resolve;job.reject=reject;}); + albumRequests.set(id,job);albumQueue.push(job);pumpAlbumRequests();return job.promise; +} +function pumpAlbumRequests(){ + while(activeAlbumRequests<4&&albumQueue.length){ + const job=albumQueue.shift(); + if(job.signals.every(signal=>signal.aborted)){albumRequests.delete(job.id);job.reject(new DOMException('Navigation cancelled','AbortError'));continue;} + activeAlbumRequests++; + (async()=>{try{ + const response=await fetch('/api/gbif/'+job.id,{signal:AbortSignal.timeout(12000)}),album=await response.json(); + if(!response.ok)throw Error(album.error||'Photo lookup failed'); + photoAlbums.set(job.id,album);job.resolve(album); + }catch(error){job.reject(error);}finally{albumRequests.delete(job.id);activeAlbumRequests--;pumpAlbumRequests();}})(); + } +} + function photoText(tag, text, className = '') { const node = document.createElement(tag); node.textContent = text; @@ -121,13 +144,7 @@ async function renderPhotos() { const classIndex = ids[rank], id = data.names[classIndex], card = cards[rank]; if (!photoNumber(id)) {card.status.textContent = 'Not a GBIF ID.'; continue;} try { - let album = photoAlbums.get(id); - if (!album) { - const response = await fetch('/api/gbif/' + id, {signal}); - album = await response.json(); - if (!response.ok) throw Error(album.error || 'GBIF lookup failed'); - photoAlbums.set(id, album); - } + const album = await referenceAlbum(id, signal); if (signal.aborted || generation !== photoGeneration) return; await showReference(card, album, classIndex, generation, signal); } catch (error) { diff --git a/mini_trainer/visualization/prototype_space/projection.js b/mini_trainer/visualization/prototype_space/projection.js index 457e6736..02604dfe 100644 --- a/mini_trainer/visualization/prototype_space/projection.js +++ b/mini_trainer/visualization/prototype_space/projection.js @@ -54,20 +54,27 @@ function drawProjection(refreshThumbnails=true) { } function projectionMouse(event){const r=projectionCanvas.getBoundingClientRect();return [(event.clientX-r.left)*1200/r.width,(event.clientY-r.top)*projectionHeight/r.height];} function projectionHit(point){if(typeof mapThumbnailHit==='function'){const hit=mapThumbnailHit(point);if(hit>=0)return hit;}let best=-1,distance=(10*1200/(projectionCanvas.getBoundingClientRect().width||1200))**2;projectionHits.forEach(([x,y],i)=>{const d=(x-point[0])**2+(y-point[1])**2;if(d{projectionPaintFrame=null;drawProjection();}); +} +function flushProjectionDraw(){if(projectionPaintFrame!==null){cancelAnimationFrame(projectionPaintFrame);projectionPaintFrame=null;drawProjection();}} projectionCanvas.onpointerdown=e=>{if(e.button!==0)return;projectionDrag={point:projectionMouse(e),view:{...projectionView},moved:false};projectionCanvas.setPointerCapture(e.pointerId);}; projectionCanvas.onpointermove=e=>{ const point=projectionMouse(e); - if(projectionDrag){const dx=point[0]-projectionDrag.point[0],dy=point[1]-projectionDrag.point[1];if(Math.hypot(dx,dy)>3)projectionDrag.moved=true;projectionView.x=projectionDrag.view.x-dx/projectionView.scale;projectionView.y=projectionDrag.view.y+dy/projectionView.scale;drawProjection();return;} + if(projectionDrag){const dx=point[0]-projectionDrag.point[0],dy=point[1]-projectionDrag.point[1];if(Math.hypot(dx,dy)>3)projectionDrag.moved=true;projectionView.x=projectionDrag.view.x-dx/projectionView.scale;projectionView.y=projectionDrag.view.y+dy/projectionView.scale;queueProjectionDraw();return;} const id=projectionHit(point); if(typeof showMapPhotoCredit==='function')showMapPhotoCredit(id); $('projection-hover').textContent=id<0?'Hover a point for its class ID.':`Class ${data.names[id]} · checkpoint row ${id}${id===selected?' · selected':data.neighbours[selected].includes(id)?' · original-space neighbour':currentProjection().neighbours[selected].includes(id)?' · nearby in this plane only':''}`; }; -projectionCanvas.onpointerup=e=>{if(!projectionDrag)return;const click=!projectionDrag.moved;projectionDrag=null;if(projectionCanvas.hasPointerCapture(e.pointerId))projectionCanvas.releasePointerCapture(e.pointerId);if(click){const id=projectionHit(projectionMouse(e));if(id>=0)selectClass(id);}}; +projectionCanvas.onpointerup=e=>{if(!projectionDrag)return;flushProjectionDraw();const click=!projectionDrag.moved;projectionDrag=null;if(projectionCanvas.hasPointerCapture(e.pointerId))projectionCanvas.releasePointerCapture(e.pointerId);if(click){const id=projectionHit(projectionMouse(e));if(id>=0)selectClass(id);}}; projectionCanvas.onpointercancel=()=>{projectionDrag=null;}; projectionCanvas.addEventListener('wheel',e=>{ e.preventDefault();const [x,y]=projectionMouse(e),old=projectionView.scale; const scale=Math.max(1,Math.min(1e7,old*Math.exp(-Math.max(-300,Math.min(300,e.deltaY))*.002))); - projectionView.x+=(x-600)*(1/old-1/scale);projectionView.y-=(y-projectionHeight/2)*(1/old-1/scale);projectionView.scale=scale;drawProjection(); + projectionView.x+=(x-600)*(1/old-1/scale);projectionView.y-=(y-projectionHeight/2)*(1/old-1/scale);projectionView.scale=scale;queueProjectionDraw(); },{passive:false}); $('projection-plane').onchange=()=>{fitProjection(data.names.map((_,i)=>i));drawProjection();}; $('projection-reset').onclick=()=>{fitProjection(data.names.map((_,i)=>i));drawProjection();}; diff --git a/mini_trainer/visualization/prototype_space/report.html b/mini_trainer/visualization/prototype_space/report.html index 9909e3a9..2373b9ad 100644 --- a/mini_trainer/visualization/prototype_space/report.html +++ b/mini_trainer/visualization/prototype_space/report.html @@ -245,10 +245,10 @@ } function selectClass(id){selected=id;$('search').value=classIdentity(id);$('selected-name').textContent=classIdentity(id);$('selected-meta').textContent=`Checkpoint row ${id} · taxonomy IDs: ${data.groups[id].join(' → ')}`;$('ties').textContent=`${data.zero_count[id]} other classes have baseline distance 0 to this class. Neighbour ranks below use the pre-CDF z-score; the baseline distances are unchanged. The log-tail view evaluates log Φ(−z) directly in float32, retaining the repository z-score. Hover a baseline distance in the table for a separate float64 reference.`;drawProfile();drawTree();drawLocal();requestClassNames();if(typeof renderPhotos==='function')renderPhotos();if(typeof drawProjection==='function')drawProjection();const pos=(positions[id]+.5)/(Math.ceil(data.names.length/data.matrix_block)*data.matrix_block)*100;document.querySelectorAll('.marker:not(.horizontal)').forEach(m=>m.style.left=pos+'%');document.querySelectorAll('.marker.horizontal').forEach(m=>m.style.top=pos+'%');} function drawLocal(){ - const svg=$('local');svg.replaceChildren();const ids=[selected,...data.neighbours[selected]],n=ids.length,stride=n*n,offset=selected*stride,mode=$('metric').value,useZ=mode!=='distance',arr=mode==='z'?zs:(mode==='logtail'||mode==='tail')?Float32Array.from(logtails,v=>-v):distances,vals=Array.from(arr.slice(offset,offset+stride));const finite=vals.filter((_,i)=>Math.floor(i/n)!==i%n),lo=useZ?Math.min(...finite):0,hi=Math.max(...finite,lo+1e-9),cell=400/n,labelled=$('class-names').checked,margin=labelled?175:62;svg.setAttribute('viewBox',`0 0 ${margin+418} ${margin+418}`); + const svg=$('local');svg.replaceChildren();const ids=[selected,...data.neighbours[selected]],n=ids.length,stride=n*n,offset=selected*stride,mode=$('metric').value,useZ=mode!=='distance',arr=mode==='z'?zs:(mode==='logtail'||mode==='tail')?logtails:distances,vals=Array.from(arr.slice(offset,offset+stride),v=>(mode==='logtail'||mode==='tail')?-v:v);const finite=vals.filter((_,i)=>Math.floor(i/n)!==i%n),lo=useZ?Math.min(...finite):0,hi=Math.max(...finite,lo+1e-9),cell=400/n,labelled=$('class-names').checked,margin=labelled?175:62;svg.setAttribute('viewBox',`0 0 ${margin+418} ${margin+418}`); const palette=t=>`hsl(${220-190*t} 65% ${94-55*t}%)`; ids.forEach((id,i)=>{const text=labelled?classLabel(id):String(i);const top=markClass(add(svg,'text',{x:margin+(i+.5)*cell,y:margin-12,'text-anchor':labelled?'start':'middle','font-size':11},text.length>25?text.slice(0,24)+'…':text),id);if(labelled)top.setAttribute('transform',`rotate(-55 ${margin+(i+.5)*cell} ${margin-12})`);markClass(add(svg,'text',{x:margin-10,y:margin+(i+.5)*cell+4,'text-anchor':'end','font-size':11},text.length>25?text.slice(0,24)+'…':text),id);}); - for(let r=0;r{$('cell-info').textContent=label;};rect.onclick=()=>selectClass(ids[c]);} + for(let r=0;r{$('cell-info').textContent=label;};rect.onclick=()=>selectClass(ids[c]);} $('cell-info').textContent='First row/column is the selected class; remaining entries follow neighbour rank. Hover for stored values.'; $('local-scale').textContent=useZ?`Local colour range: ${mode==='z'?'z':'−log₁₀ tail'} ${fmt(lo)} (pale) → ${fmt(hi)} (dark). Diagonal masked. Scale adapts to this neighbourhood.`:`Local colour range: distance 0 (dark) → ${fmt(hi)} (pale), log1p display. Diagonal masked. Scale adapts to this neighbourhood.`; if(mode==='tail')$('local-scale').textContent=`Upper-tail q: ${probabilityText(-lo)} → ${probabilityText(-hi)}; colours use −log₁₀ q. Diagonal masked.`; @@ -314,3 +314,5 @@ document.documentElement.style.setProperty('--toolbar-height',entries[0].target.getBoundingClientRect().height+'px'); }).observe(document.querySelector('.controls')); + + diff --git a/mini_trainer/visualization/prototype_space/state.js b/mini_trainer/visualization/prototype_space/state.js new file mode 100644 index 00000000..c0ef67ae --- /dev/null +++ b/mini_trainer/visualization/prototype_space/state.js @@ -0,0 +1,61 @@ +/* Versioned presentation state. Numerical data and transient image motion are excluded. */ +const viewerStateControls=['workspace-view','projection-plane','metric','global-metric','range-preset','range-low','range-high','score-display','class-names','gbif-names','photo-enabled','map-photos','map-photo-size','map-photo-density','map-photo-overlap','map-photo-labels','map-photo-push','map-photo-aspect','map-photo-anchors','projection-edges']; +let restoringViewerState=false,viewerStateTimer,viewerServerToken=null,viewerStateChanged=false,viewerStateWrites=Promise.resolve(); +const viewerDocumentKey='prototype-case:'+(Object.values(cases).find(c=>!c.metadata.synthetic)?.metadata.checkpoint_sha256||Object.keys(cases).join(':')); +function viewerIdentity(){ + let hash=2166136261;for(const name of data.names)for(const c of name+'\0')hash=Math.imul(hash^c.charCodeAt(0),16777619); + return [data.metadata.checkpoint_sha256||'synthetic',data.metadata.seed||0,$('case').value,data.names.length,hash>>>0].join(':'); +} +function captureViewerState(){ + return {version:1,identity:viewerIdentity(),selected:data.names[selected],tree:treeFocus,view:{...projectionView},controls:Object.fromEntries(viewerStateControls.map(id=>[id,$(id).type==='checkbox'?$(id).checked:$(id).value]))}; +} +function stateMessage(message){$('viewer-state-status').textContent=message;} +function saveViewerState(){ + if(restoringViewerState)return; + try{localStorage.setItem('prototype-view:'+viewerIdentity(),JSON.stringify(captureViewerState()));localStorage.setItem(viewerDocumentKey,$('case').value);stateMessage('View saved locally.');} + catch{stateMessage('Browser storage unavailable. Export state to keep this view.');} + if(viewerServerToken){const body=JSON.stringify({key:viewerDocumentKey,state:{case:$('case').value,view:captureViewerState()}});viewerStateWrites=viewerStateWrites.then(()=>fetch('/api/view-state',{method:'POST',headers:{'Content-Type':'application/json','X-Explorer-Token':viewerServerToken},body,keepalive:true})).catch(()=>stateMessage('Local server state unavailable; browser save/export remains available.'));} +} +function applyViewerState(state){ + if(state?.version!==1||state.identity!==viewerIdentity())throw Error('State belongs to a different model/case or unsupported version.'); + const index=data.names.indexOf(state.selected),v=state.view; + if(index<0||!v||![v.x,v.y,v.scale].every(Number.isFinite)||v.scale<=0||!Number.isInteger(state.tree)||state.tree<0||state.tree>=data.names.length*2-1)throw Error('Invalid saved selection or geometry.'); + if(!state.controls||typeof state.controls!=='object')throw Error('Missing saved controls.'); + for(const [id,value] of Object.entries(state.controls)){ + if(!viewerStateControls.includes(id))continue;const node=$(id); + if(node.type==='checkbox'){if(typeof value!=='boolean')throw Error('Invalid checkbox state.');} + else if(node.tagName==='SELECT'){if(![...node.options].some(o=>o.value===value))throw Error('Unavailable saved view or projection.');} + else if(!Number.isFinite(Number(value))||(node.min!==''&&Number(value)Number(node.max)))throw Error('Invalid saved control range.'); + } + restoringViewerState=true; + try{ + for(const [id,value] of Object.entries(state.controls)){if(!viewerStateControls.includes(id))continue;const node=$(id);if(node.type==='checkbox')node.checked=value;else node.value=value;} + setWorkspaceView($('workspace-view').value);treeFocus=state.tree;selectClass(index); + $('score-note').hidden=$('score-display').value!=='tail';drawHistogram();drawGlobal();setThumbnailAnchors(); + // Wait for the new focused layout's ResizeObserver before restoring its transform. + requestAnimationFrame(()=>requestAnimationFrame(()=>{projectionView={...v};drawProjection();restoringViewerState=false;stateMessage('Saved view restored.');})); + }catch(error){restoringViewerState=false;throw error;} +} +function restoreViewerState(){ + try{const raw=localStorage.getItem('prototype-view:'+viewerIdentity());if(raw)applyViewerState(JSON.parse(raw));else stateMessage('No saved view for this model/case.');} + catch(error){stateMessage('Saved view not restored: '+error.message);} +} +function queueViewerState(){if(restoringViewerState)return;viewerStateChanged=true;clearTimeout(viewerStateTimer);viewerStateTimer=setTimeout(saveViewerState,400);} +const stateMenu=document.createElement('details');stateMenu.className='label-settings';stateMenu.innerHTML='Saved view
'; +document.querySelector('.controls').append(stateMenu); +$('viewer-state-export').onclick=()=>{const url=URL.createObjectURL(new Blob([JSON.stringify(captureViewerState(),null,2)],{type:'application/json'}));const a=document.createElement('a');a.href=url;a.download='prototype-view.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}; +$('viewer-state-import').onchange=async()=>{try{const file=$('viewer-state-import').files[0];if(file)applyViewerState(JSON.parse(await file.text()));}catch(error){stateMessage(error.message);}}; +$('viewer-state-reset').onclick=async()=>{restoringViewerState=true;clearTimeout(viewerStateTimer);try{localStorage.removeItem('prototype-view:'+viewerIdentity());await viewerStateWrites;if(viewerServerToken)await fetch('/api/view-state',{method:'POST',headers:{'Content-Type':'application/json','X-Explorer-Token':viewerServerToken},body:JSON.stringify({key:viewerDocumentKey,state:null})});}catch{}location.reload();}; +document.addEventListener('change',event=>{if(event.target.id==='case')restoreViewerState();else queueViewerState();}); +document.addEventListener('click',queueViewerState);document.addEventListener('pointerup',queueViewerState);document.addEventListener('wheel',queueViewerState,{passive:true}); +window.addEventListener('pagehide',saveViewerState); +try{const savedCase=localStorage.getItem(viewerDocumentKey);if(savedCase&&cases[savedCase]&&$('case').value!==savedCase){$('case').value=savedCase;setCase();}}catch{} +restoreViewerState(); + +// The local launcher can change port between sessions, unlike browser storage. +if(location.protocol.startsWith('http'))fetch('/api/view-state?key='+encodeURIComponent(viewerDocumentKey)).then(r=>r.ok?r.json():null).then(result=>{ + if(!result)return;viewerServerToken=result.token; + if(!viewerStateChanged&&result.state?.view&&cases[result.state.case]){ + $('case').value=result.state.case;setCase();applyViewerState(result.state.view); + } +}).catch(()=>{}); diff --git a/mini_trainer/visualization/prototype_space/thumbnails.js b/mini_trainer/visualization/prototype_space/thumbnails.js index fca6e408..8f9492d2 100644 --- a/mini_trainer/visualization/prototype_space/thumbnails.js +++ b/mini_trainer/visualization/prototype_space/thumbnails.js @@ -280,8 +280,7 @@ async function renderMapThumbnails(){ const id=data.names[box.id]; if(retained.has(box.id)){loaded++;const old=retained.get(box.id);old.button.style.zIndex=String(boxes.length-ranks.get(box.id));old.button.style.borderColor=box.id===selected?'#c2344b':data.neighbours[selected].includes(box.id)?'#087e8b':'#7c90a3';return;} try{ - let album=photoAlbums.get(id); - if(!album){const response=await fetch('/api/gbif/'+id,{signal});album=await response.json();if(!response.ok)throw Error(album.error||'Photo lookup failed');photoAlbums.set(id,album);} + const album=await referenceAlbum(id,signal); if(signal.aborted||generation!==mapThumbGeneration)return; if(!album.photos.length){mapThumbFailures.add(id);unavailable++;status();return;} const choice=(photoChoices.get(id)||0)%album.photos.length,photo=album.photos[choice]; diff --git a/tests/utils/test_prototype_launcher.py b/tests/utils/test_prototype_launcher.py index 27fed569..5394914e 100644 --- a/tests/utils/test_prototype_launcher.py +++ b/tests/utils/test_prototype_launcher.py @@ -51,7 +51,15 @@ def test_browser_upload_and_recovery(tmp_path): log = tmp_path / "server.log" with log.open("w") as output: process = subprocess.Popen( - [sys.executable, "-m", "mini_trainer.visualization.prototype_space", "--no-browser", "--pca-only"], + [ + sys.executable, + "-m", + "mini_trainer.visualization.prototype_space", + "--no-browser", + "--pca-only", + "--cache-dir", + str(tmp_path / "cache"), + ], stdout=output, stderr=subprocess.STDOUT, ) @@ -97,3 +105,149 @@ def upload(content): finally: process.terminate() process.wait(timeout=10) + + +def test_analysis_cache_reuses_data_and_refreshes_assets(tmp_path, monkeypatch): + from mini_trainer.visualization.prototype_space import explore + from mini_trainer.visualization.prototype_space.launch import generate + + weights = tmp_path / "weights.pt" + checkpoint(weights) + cache = tmp_path / "cache" + first = generate(weights, tmp_path / "first", angular=False, cache_dir=cache) + reference = first.with_name("report-data.json").read_bytes() + + def forbidden(*args, **kwargs): + pytest.fail("cache hit must not analyze") + + monkeypatch.setattr(explore, "create_report", forbidden) + second = generate(weights, tmp_path / "second", angular=False, cache_dir=cache) + assert second.with_name("report-data.json").read_bytes() == reference + assert "captureViewerState" in second.read_text() + assert "__STATE_SCRIPT__" not in second.read_text() + + +def test_analysis_cache_invalidates_and_rejects_partial_or_corrupt_entries(tmp_path): + from mini_trainer.visualization.prototype_space.cache import analysis_key, read_analysis, store_analysis + + weights = tmp_path / "weights" + weights.write_bytes(b"model one") + options = {"angular": False, "synthetic": False, "threads": 4} + key, contract = analysis_key(weights, **options) + assert key != analysis_key(weights, **{**options, "angular": True})[0] + weights.write_bytes(b"model two") + assert key != analysis_key(weights, **options)[0] + root, output = tmp_path / "cache", tmp_path / "output" + output.mkdir() + for name in ("report-data.json", "summary.json"): + (output / name).write_text('{"valid": true}') + assert read_analysis(root, key, contract) is None + store_analysis(root, key, contract, output) + assert read_analysis(root, key, contract) is not None + (root / key / "report-data.json").write_text('{"altered": true}') + assert read_analysis(root, key, contract) is None + store_analysis(root, key, contract, output) + assert read_analysis(root, key, contract) is not None + store_analysis(root, key, contract, output, max_bytes=0) + assert read_analysis(root, key, contract) is None + + +def test_cancelled_job_cannot_publish_after_replacement(tmp_path, monkeypatch): + import threading + from types import SimpleNamespace + + from mini_trainer.visualization.prototype_space import launch + + entered, release = threading.Event(), threading.Event() + processes = [] + + class Process: + def __init__(self, command, **kwargs): + self.output = launch.Path(command[command.index("--output") + 1]) + self.output.mkdir() + self.code = None + self.first = not processes + processes.append(self) + + def poll(self): + return self.code + + def terminate(self): + self.code = -15 + + def wait(self, timeout=None): + if self.first and timeout is None: + entered.set() + release.wait(5) + if self.code is None: + self.code = 0 + (self.output / "report-data.json").write_text(json.dumps({"case": {"metadata": {}, "names": ["1"]}})) + (self.output / "explorer.html").write_text("old" if self.first else "new") + return self.code + + monkeypatch.setattr(launch.subprocess, "Popen", Process) + workspace = launch.Workspace(tmp_path) + workspace.service = SimpleNamespace(allowed_ids=set()) + workspace.start(tmp_path / "old.pt") + assert entered.wait(5) + workspace.start(tmp_path / "new.pt") + workspace.workers[-1].join(5) + assert workspace.status()["state"] == "ready" + release.set() + workspace.close() + assert (workspace.public / "explorer.html").read_text() == "new" + assert workspace.ready + assert not (tmp_path / "job-1").exists() + assert all(not worker.is_alive() for worker in workspace.workers) + + +def test_view_state_survives_new_session_and_cache_clear(tmp_path): + from mini_trainer.visualization.prototype_space.cache import clear_analysis_cache + from mini_trainer.visualization.prototype_space.launch import Workspace + + cache = tmp_path / "cache" + first, second = tmp_path / "first", tmp_path / "second" + first.mkdir() + second.mkdir() + before = Workspace(first, cache_dir=cache) + state = {"case": "Production checkpoint", "view": {"version": 1, "selected": "42"}} + before.write_view_state("model-hash", state) + clear_analysis_cache(cache) + after = Workspace(second, cache_dir=cache) + assert after.token != before.token + assert after.read_view_state("model-hash") == state + assert after.read_view_state("other-model") is None + after.write_view_state("model-hash", None) + assert before.read_view_state("model-hash") is None + + +def test_cancel_terminates_real_analysis_process(tmp_path, monkeypatch): + from types import SimpleNamespace + + from mini_trainer.visualization.prototype_space import launch + + original = subprocess.Popen + processes = [] + + def sleeping_analysis(command, **kwargs): + process = original([sys.executable, "-c", "import time; time.sleep(60)"], **kwargs) + processes.append(process) + return process + + monkeypatch.setattr(launch.subprocess, "Popen", sleeping_analysis) + workspace = launch.Workspace(tmp_path) + workspace.service = SimpleNamespace(allowed_ids=set()) + workspace.start(tmp_path / "weights.pt") + try: + deadline = time.monotonic() + 5 + while not processes and time.monotonic() < deadline: + time.sleep(0.01) + assert processes and processes[0].poll() is None + workspace.cancel() + workspace.workers[0].join(5) + assert processes[0].poll() is not None + assert workspace.status()["state"] == "cancelled" + assert not (tmp_path / "job-1").exists() + assert not (workspace.public / "explorer.html").exists() + finally: + workspace.close() From 1d2dae0251989b067908a7c06c0701a890b11580 Mon Sep 17 00:00:00 2001 From: asgersvenning Date: Fri, 11 Sep 2026 17:37:49 +0200 Subject: [PATCH 27/34] full training on ucloud post-mortem --- dev/ucloud/README.md | 5 + docs/roadmap.md | 16 +- docs/training-workflow-postmortem.md | 392 +++++++++++++++++++++++++++ 3 files changed, 410 insertions(+), 3 deletions(-) create mode 100644 docs/training-workflow-postmortem.md diff --git a/dev/ucloud/README.md b/dev/ucloud/README.md index 3d9cd5f4..bf336df8 100644 --- a/dev/ucloud/README.md +++ b/dev/ucloud/README.md @@ -1,5 +1,10 @@ # UCloud global_lepi training comparison +For findings from the completed production campaign and proposed next-run +improvements, see the [workflow post-mortem](../../docs/training-workflow-postmortem.md). +The commands below retain historical comparison profiles and pins; they are not +a newly qualified production recipe for every node. + Run inside **one allocated node**, with 1, 2, 4 or 8 visible GPUs. More than one GPU uses one `torchrun` process per GPU and the package's existing DDP trainer (including SyncBatchNorm). One GPU uses ordinary training. Do not invoke the diff --git a/docs/roadmap.md b/docs/roadmap.md index 6ca554bb..374ae94a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,8 +1,18 @@ # Repository strengthening roadmap -The active quantization feature branch has a separate -[execution roadmap](quantization-roadmap.md) covering measured bottlenecks, -target-machine dependencies, integration gates and deferred work. +The quantization branch has been merged into master. Its +[execution roadmap](quantization-roadmap.md) remains the specialist backlog; +fully quantized training performance is not established by that merge. + +The completed four-GPU production campaign is assessed in the +[training workflow post-mortem and next-run plan](training-workflow-postmortem.md). +For the next training run, first deliver its bounded P0 workflow slice: durable +stage state and recovery, preallocated evaluation/export preparation, and separate +compute/storage qualification. Then measure concurrent staging before investing in +prepared shards. Preserve normal CLIs, operator overrides, figures and W&B. +These are proposed development priorities, not implemented capabilities. They fit +the safeguards, export, loading and evaluation boundaries below; defer another +large optimization matrix until it can change a specific production decision. This is the implementation order. Each increment should leave the existing default training and prediction interfaces working and include its own validation evidence. diff --git a/docs/training-workflow-postmortem.md b/docs/training-workflow-postmortem.md new file mode 100644 index 00000000..67ec9fc2 --- /dev/null +++ b/docs/training-workflow-postmortem.md @@ -0,0 +1,392 @@ +# Production training workflow: post-mortem and next-run plan + +Status: proposed development plan; no implementation is authorized by this document. +Campaign: 10–11 September 2026. The quant branch has since been merged into master. + +## Executive assessment + +The campaign produced a useful model, completed four-GPU training with figures and +W&B, evaluated in-domain and independent expert data, exported floating and PTQ +ONNX artifacts, and packaged the evidence with verified checksums. The original +objective of faster, memory-efficient fully quantized training was not established. +These are separate outcomes: merging useful improvements does not qualify every +experimental backend or prove absence of regressions against a full master run. + +The largest opportunity for the next run is operational reliability and data access, +not another optimization matrix. Small warmed trials selected a viable compute +configuration but substantially underestimated first-pass storage costs. Repeated +manual handoffs, opaque status and late preparation of evaluation/export consumed +scarce allocation time. Preserve the flexible CLI/API design and human decisions; +make the routine steps repeatable, inspectable and recoverable. + +This assessment uses the operator-provided logs, metric tables and command results +from the campaign, checked against the current repository interfaces. It is not an +independent audit of the downloaded archive or a replay of the experiments. The +archive checksum was reported as passing. Its final public URL and immutable model +hashes must be attached when available; do not invent them from version names. +Older notes that say production was incomplete, or retain four loader workers as +the production setting, are superseded by this campaign evidence. + +## Outcomes and strength of evidence + +| Area | Observed result | What it establishes / does not establish | +| --- | --- | --- | +| Production | EfficientNetV2-S, normalized hierarchical head, size 384; 4 full B200 GPUs; batch 256/rank; FP16 AMP; model compilation; 32 loader workers/rank in the successful run | A functional recipe on this allocation, not an optimum across precisions, machines or models | +| Optional features | Optimizer compilation, explicit CUDA prefetch, INT8 training and EMA disabled | Deliberate scope reduction; EMA and native INT8 DDP remain unsupported | +| Training | 30 epochs, 17:34:23 total; training 16:27:22, evaluation timer 43:33; best model reported at epoch 30 | Finished within allocation; phase timers do not cover every logging/teardown cost | +| Final metrics | Train species micro accuracy 98.1645%; validation species/genus/family 94.2353% / 97.6693% / 99.5047%; finite recorded losses | Strong completed-run result; not a controlled comparison with the old master model | +| In-domain test | 632,913 images; baseline micro accuracy 94.21% / 97.64% / 99.50%; macro accuracy 92.94% / 97.00% / 98.48% | Test agrees closely with validation; no dataset-leakage audit was completed here | +| Expert test | 58,640 images, 522 species; baseline species micro accuracy 57.59% all labels, 66.74% known only | Substantial domain/vocabulary shift remains despite strong in-domain results | +| Figures/W&B | Full-head diagnostics and distributed logging completed throughout production | Required product functionality worked; cost and summary semantics still need improvement | +| Resume probe | All 4 ranks reported identical checkpoint hash, start epoch 3 and restoration of model/optimizer/scheduler/scaler | Controlled restoration worked; arbitrary interrupted stochastic continuation is not proven bit-exact | +| FP32 ONNX | Dynamic batches 1, 2, 4 passed at rtol=1e-4, atol=1e-4; largest observed absolute error 3.84e-5 | Synthetic-input numerical parity, not real-image end-to-end parity or target-GPU performance | +| ONNX PTQ | 128 training images; Percentile 99.9; Q/DQ graph validation and finite runtime smoke passed | Quantized ONNX artifact exists; retained quality and native integer execution remain unqualified; no quantized .pt was created | +| Retention | 23.71 GiB before ZIP; ZIP integrity check and subsequent archive checksum passed | Evidence was preserved; size, discoverability and clean-environment reuse need refinement | + +### Allocation timeline and where time was exposed + +The four-GPU allocation began around 17:24. The successful production training +started around 19:01 (inferred from the final duration), about 97 minutes later. +Training finished around 12:35 the following day; test inference was reported +complete at 16:11, evaluation output around 16:44, and archive verification at +17:13, shortly before the 17:24 expiry. These are operational timestamps, not a +profile attributing every minute to a specific component. Qualification had value; +not all of this interval was avoidable waste. Nevertheless, hours of post-training +storage/evaluation and last-minute packaging were clearly on the critical path. + +The reported source split contained 5,063,857 training images, 633,224 validation +images and 632,913 test images. Thirty training epochs divided by the reported +training timer imply about 2,564 images/s aggregate, or 641/rank, consistent with +the observed roughly 650 training images/s/GPU. Use the training split rather than +all six-million-plus source rows for runtime planning, and budget validation and +diagnostics separately. This cross-check also illustrates why units belong in the +report rather than being reconstructed from ETA afterward. + +### Compute selection: useful, but deliberately bounded + +Reported aggregate warmed training throughput from four-GPU qualification: + +| Batch per GPU | Images/s | Peak allocated bytes per reported maximum | Peak reserved fraction | +| --- | ---: | ---: | ---: | +| 32 | 1,059.648 | 8,332,350,464 | 0.059 | +| 64 | 1,741.601 | 15,640,267,776 | 0.098 | +| 128 | 2,272.305 | 30,251,580,416 | 0.178 | +| 256 | 2,517.928 | 59,508,456,960 | 0.324 | + +Increasing batch 128 to 256 gained about 10.8% throughput with nearly twice the +allocated memory. Larger batches were not exhaustively tested. Spare memory is an +opportunity, not evidence that a further increase pays off. Use equal-memory-budget +and time-to-quality comparisons where appropriate, not just equal batch size. +Changing global batch also changes optimizer updates per epoch and schedule behavior. +The qualitative impression of improved prototypes/generalization at larger batches +is useful feedback, not a controlled convergence result. + +Earlier single-GPU trials repeatedly found model compilation reduced later-epoch +training time by roughly one third and allocated memory by roughly one third. +Optimizer compilation alone or combined with model compilation did not improve +those steady-state timings and added substantial cold startup. Explicit prefetch +showed no convincing additional gain. INT8 combined runs timed out and were not +qualified. Keep these negative results so that the next run does not repeat the +same matrix without a new hypothesis, implementation change or target requirement. +FP16 is the tested baseline; BF16 and other compiler modes were not eliminated by +a comprehensive comparison. + +## Failure modes and lessons + +### 1. Storage behavior invalidated small-subset extrapolation + +Full-data training and test inference initially stalled while data workers had low +CPU use and waited in `D` state / `folio_wait_bit_common`. A 32-step storage window +spent about 146–150 of 211 seconds waiting for the loader across ranks. Test +inference reached only 69/2,473 batches after 43 minutes. GPU utilization snapshots +sometimes looked high despite poor progress; they did not identify the bottleneck. + +The mounted filesystem was WEKA. Repeated reads of the same expert subset went +from hundreds of seconds to below one second. The operator observed improved cold +staging with 512 readers, and a later disjoint-sample read sweep favored 512 readers +(~117 images/s confirmation median) over lower concurrency. Another workload with +larger files provisionally favored 64. These are workload/cache-state results, not +universal worker defaults. Strong evidence supports cache-state-dependent storage +latency; the exact client/server cache or network mechanism was not instrumented. + +The successful production run used 32 loader workers/rank and eventually stabilized +near 650 training images/s/GPU and 2,000–2,500 evaluation images/s/GPU. The long +warmup within the run supports a cache-related explanation; it does not quantify a +separate causal percentage for each storage/concurrency effect. Practical mitigation +need not wait for a filesystem-internal diagnosis. + +Lessons: + +- Separate process workers for decode/augmentation from concurrent outstanding + encoded-byte reads. More processes are not the only way to hide blocking I/O. +- Measure first-pass and repeated-pass behavior. Disjoint paths avoid direct sample + reuse but cannot guarantee cold shared caches. Never drop global caches on a + shared allocation to manufacture a benchmark. +- Start with bounded but genuinely high concurrency candidates when latency is + evidenced; do not spend the allocation creeping from 4 to 8 to 16 threads. +- Separate reader startup, first result, steady work and drain/cancellation time. + Time-limited trials can be informative without completing their target file count. +- Report images/s and bytes/s, file-size distribution, errors, actual concurrency + and sample coverage. A sampled optimum is a provisional operating point. +- Prefer optional staging/encoded-byte preparation. Decoded caching of millions of + images is not a safe default even on a high-RAM node. + +### 2. Orchestration was too easy to interrupt and too hard to resume + +Examples included relative commands launched from the wrong directory, missing +inference YAML files, torchrun interpreting `--run` as its own abbreviated option, +preparation tied to historical commits, and cancelled stages refusing an existing +directory. A trial had final weights and finite metrics but was marked timed out +when the shared wall budget expired. This is neither evidence of failed training +nor permission to relabel the whole process successful: export, logging, teardown +or restore checks may still be incomplete. + +The package pin and checkout revision sometimes differed intentionally, while +`PYTHONPATH` overrides added a third possible source identity. Hand-built commands +made that hard to see. The late archive command also lost its terminal connection; +file descriptors could not recover previously lost stdout. Durable files and a +completion checksum, rather than terminal appearance, ultimately established success. + +The necessary response is a small extension to existing harness state and commands, +not a general workflow engine. Record phase completion separately from process exit, +retain partial results, and make retry/resume/archive-incomplete actions explicit. +A checkpoint's existence is not a replacement for a loss audit or clean shutdown. + +### 3. Qualification tested the right features, but not early enough in combination + +Figures were initially disabled to avoid expensive rendering, although they were a +required training diagnostic. At larger taxonomy size the dendrogram caused long +validation pauses and recursion warnings. Confusion/dendrogram improvements allowed +production diagnostics to remain on: at 12,632 species, confusion generation was +about 8–13 seconds and warmed dendrogram rendering about 12–13 seconds. First label +resolution added roughly a minute in one run; subsequent resolution was much faster. + +Whole-matrix confusion patterns and prototype organization matter to the operator. +Do not replace them with a few selected classes or disable them as the standard +performance fix. Preserve visual interpretation while bounding file/display costs. +Which hierarchy levels render must be an explicit option recorded in the resolved +configuration; the unexpected level-0-only output showed that defaults were opaque. + +The production smoke should exercise the actual CLI, full head dimensions, figures, +W&B, validation and save/reload on the intended topology. A small subset tests those +contracts; a separate bounded storage probe tests uncached access. Neither replaces +the other. Four GPUs passing does not establish eight-GPU behavior. + +### 4. Numerical and compiler warnings need a triage budget + +Early validation loss NaNs appeared in both branch baselines and optional-feature +runs; later epochs could be finite and the final production loss audit passed. +This weakens attribution to prefetch or quantization but does not prove the NaNs +were harmless. Keep a cheap first-occurrence record: phase/batch/sample identities, +input/output/loss finiteness, dtype and enabled features. Capture a bounded replay +only when triggered; avoid a permanent expensive debug path. + +Observed compiler issues were stochastic-depth specialization/recompile limits, +hierarchy initialization using `Tensor.item()`, and DDP gradient/bucket stride +mismatches. They are performance targets, not demonstrated model-quality failures. +Prioritize a change only after a representative trace shows repeated fallback, +recompilation or copy cost. Do not globally raise limits, suppress warnings or change +numerics merely to make the log clean. Keep EMA repair outside the next-run critical +path unless the operator explicitly needs EMA. + +### 5. Inference ingestion and evaluation preparation lagged behind training + +Folder inference unnecessarily depended on training-oriented index construction +and model-vocabulary membership. Valid expert species could be absent from the +model. A taxonomy rank-count/index mismatch was initially plausibly attributed to +network resolution. The bounded discovery fix was useful; broader ingestion should +keep discovery, taxonomy resolution, split policy and model indexing separate. + +A model-vocabulary filter restricts candidate predictions, not the validity of +ground-truth labels. Future source formats must reuse this separation for both +training and inference. External taxonomy access needs cache/provenance and clear +transport-versus-local-mapping errors; a hardcoded socket timeout is not a batch +or job deadline. + +Inference should normally require input, output and weights. Derive shape, +preprocessing and head/collector behavior from saved metadata, with explicit +operator overrides and actionable errors for ambiguous legacy metadata. Generate +resolved config for inspection; do not fill YAML with guessed defaults. + +Staging eventually made expert inference finish in 2:49 and full test inference in +30:02, after slow source reads had dominated. Prepare the evaluation plan before +training and overlap independent preparation where resource budgets allow. Do not +launch competing cold scans blindly. Pin mini_metrics in its own compatible Python +environment and preserve its pretty tables plus machine-readable outputs. + +### 6. Evaluation success and deployment readiness are different gates + +The expert set's 16 unseen species accounted for 8,042 images (13.71%) and errors; +five of those species contributed 84.3% of unseen-species errors. Error concentration +also existed among known species. This supports the operator's interpretation of +uneven domain/vocabulary effects. Geographic candidate lists are promising opt-in +priors, but their provenance/scope and excluded true labels must remain visible. +No regional-filter accuracy gain was established in the supplied results. + +Retain all-label/known-only and unfiltered/threshold-optimized reports. Clarify +micro versus macro, rank, aggregation period and abstention denominator. In-domain +optimized species accuracy was 95.73% at 97.46% coverage; expert known-only optimized +species accuracy was 92.25% at 53.66% coverage. These are not equivalent operating +points. Threshold search/split semantics and the distinction between an applied +threshold and a subsequently reported optimum must be documented before rollout. +Choose deployment thresholds using suitable calibration data and evaluate them +frozen; current optimized test reports remain exploratory. Empty-abstention summary +NaNs are distinct from nonfinite model outputs or loss. + +FP16 AMP training did not imply an FP16 ONNX export. FP32 export required an explicit +absolute-tolerance adjustment after small near-zero discrepancies; tolerances and +errors were retained. Do not silently weaken defaults. Add real-image parity and +rank/top-k changes alongside absolute errors, with predeclared acceptable limits. +PTQ happened directly on ONNX, so no quantized PyTorch artifact exists. Q/DQ node +counts and runtime loadability are not proof of integer kernel placement, target +speed or retained accuracy. These must be separate candidate acceptance checks. + +### 7. Packaging worked, but happened too late and bundled too much together + +The verified archive preserved a large set of results, logs and figures. Packaging +was improvised near expiry, and the chosen public bundle also contained a resume +checkpoint and extensive history. Use one relocatable directory but distinguish a +small deployment subset, reproducibility evidence and optional training archive. +Never omit ONNX external tensors. Retain omissions in the manifest; calibration +manifests referring to omitted tensors must not imply self-contained replay. + +A publication command should inventory intended files, sizes and public/private +scope before copying; omit credentials, caches and raw training images by default. +Record package revision, harness revision, weights hash, splits, preprocessing, +class mapping, evaluation versions, export tolerances and target qualification. +Write logs to disk from process start and publish completion atomically. Checksums +establish integrity, not accuracy, data provenance or deployment correctness. + +## Prioritized development plan + +Effort below is a planning estimate for implementation plus focused validation, +not a promise: S = roughly 1–2 developer days; M = several days; L = one or more +weeks with integration work. Re-estimate after inspecting the existing boundary. +Prefer the smallest vertical slice that removes an observed failure. + +| Priority | Increment | Value / effort | Done when | +| --- | --- | --- | --- | +| P0 | Durable stage state and resolved execution plan | High reliability, S–M | A fresh job can inspect, run, stop, retry and resume each existing stage; logs survive disconnect; completed stages are reused only after input/hash validation | +| P0 | Prepare evaluation/export/publication before allocation | High saved allocation time, S–M | One tiny installed-CLI fixture runs train → predict → metrics → export → package; commands, required paths and dependencies are checked before expensive work | +| P0 | Separate compute qualification from storage qualification | High decision value, S | A compact report distinguishes startup, warm training, first-pass I/O, validation/figures and teardown; it states scope and units and supplies a bounded next action | +| P1 | Reusable concurrent read/staging calibration | High on affected storage, M | Startup and work budgets are separate; incomplete trials remain informative; bounded cancellation/recovery, disjoint sampling, file sizes and actual concurrency are reported; operator override survives | +| P1 | Optional prepared encoded-byte dataset round trip | Potentially high recurring gain, L | Existing input compatibility matrix and sampler/DDP contracts pass; end-to-end savings exceed preparation/maintenance cost on representative data | +| P1 | Real-image export and candidate evaluation | High rollout confidence, M | Same preprocessed held-out inputs compare PyTorch, floating ONNX and optional PTQ; score/rank changes, quality, coverage, placement and target resource evidence are retained | +| P2 | Figure/metadata caching and explicit figure policy | Medium operational gain, S–M | Whole-matrix diagnostics and requested hierarchy levels remain inspectable; first/warm rendering and file sizes are recorded without repeated taxonomy resolution | +| P2 | Targeted compiler/numerical fixes | Conditional gain, M per demonstrated issue | A bounded reproducer demonstrates the cost/failure and a paired check verifies the fix without changing checkpoint or prediction contracts | +| Deferred | Native INT8 DDP, EMA, expanded precision/compiler matrices, automated allocation, universal inference CLI | Uncertain value, M–L | A concrete requirement or measured bottleneck justifies a separately reviewed experiment; none blocks the baseline run | + +### A. Next-run minimum: complete the P0 vertical slice first + +Build on `dev/ucloud/{setup.sh,compare.py,scaling.py,production.py}`, the evaluation +scripts and existing ONNX tools. Do not replace the public training/inference APIs. +A small shared run manifest should reference existing stage outputs rather than +copy their schemas into another database. Record stage input identities, resolved +command/config, start/end, output/log paths, exit cause and completion checks. +Differentiate preparation, compilation, training, validation/figures, checkpoint, +logger synchronization and teardown. Start with phases that are already observable. + +Use absolute paths in generated commands, explicit interpreter selection, package +and harness identities, and inspected CLI-over-YAML precedence. Keep W&B's existing +interactive authentication in the foreground. No secrets in manifests. Pin an +accepted master revision; do not embed a permanent dependency on the old quant +branch name or four-GPU topology in otherwise reusable planning helpers. + +Define an allocation deadline separately from stage limits, compilation allowance +and cleanup reserve. Human pauses consume allocation time but must not masquerade +as model regressions. Estimate feasible epochs from measured full-data throughput, +actual training split count, validation cost and reserve; do not silently alter the +learning-rate schedule to fit. At a supported checkpoint boundary, give the operator +an explicit continuation/stop decision. Do not promise exact arbitrary-batch resume +without the necessary RNG/sampler contract. + +Acceptance should include interruption after preparation, failed child exit, timeout +after checkpoint save, logger teardown failure and re-running a completed stage. +Use tiny fixtures and existing integration tests, not a second production matrix. +A single topology smoke on a changed target remains necessary. + +### B. Storage work: validate the cheap mitigation before building shards + +First improve the existing calibration/staging helper and, if needed, add bounded +encoded-byte read-ahead inside the current reader boundary. Bound requests and +bytes, preserve sample order, propagate failures with identities, and avoid repeating +large Python metadata per reader. Do not conflate read concurrency, DataLoader +processes and CUDA prefetch. Keep direct filesystem loading available. + +Only then implement the optional prepared-data backend already specified in the +[main roadmap](roadmap.md#optional-dataset-preparation-for-scalable-loading). +Start with byte-preserving indexed shards and explicit manifests. Defer distributed +cache eviction services and alternative shuffle policies until measured need. +Prepared formats must support all existing cases or explicitly remain an opt-in +partial prototype; they must never silently redefine splits, labels or sample order. + +Decision rule: report total preparation plus expected training/evaluation time and +break-even reuse count. Prefer a modest throughput gain delivered simply over a +complex backend that saves less than its preparation cost for the intended run. +Higher-concurrency staging may be enough for an immediate run; repeated campaigns +on the same corpus strengthen the case for persistent preparation. + +### C. Keep human decisions first-class + +The proposed workflow exposes `plan`, per-stage execution, compact `status`, and +explicit retry/resume actions; exact command spelling is an implementation choice. +Each stage can still be run through the normal CLI. The human can inspect figures, +change a proposed batch/worker count, select a candidate, pause or reject promotion. +Changes produce a new resolved configuration and identity; completed unrelated +stages are retained rather than overwritten or automatically rerun. + +Operator decisions are required at scientific boundaries: dataset/split choice, +global batch and schedule, geographic priors, thresholds, acceptable PTQ quality +loss and production promotion. Routine bounded retries and report generation do +not require repeated permission prompts. Do not turn every warning into a blocker. +Expose reason, scope and recovery command when intervention really is required. + +The next-run sequence is: prepare the plan and tiny end-to-end fixture before +allocation; inspect the actual node/storage; perform one production-feature smoke +and bounded storage calibration; choose a batch using existing evidence plus at +most a few useful candidates; verify save/reload; launch production in the same +allocation; evaluate and export at completion; finalize durable artifacts. Preserve +manual allocation and permit an explicit decision to skip optional experiments. + +## Experiment and validation budget + +- Every experiment states the decision it changes, baseline, measurement scope, + maximum elapsed/allocated GPU time, and stop criterion before launch. +- Reuse the qualified floating recipe unless hardware, code or workload changes + invalidate it. Run a full old-master training comparison only as a separately + funded scientific question, not a prerequisite for ordinary deployment. +- For the first batch/worker search, set a small candidate budget. Stop when gains + are within observed noise or too small to affect run completion. Expand only with + evidence and an operator decision; do not exhaust a Cartesian product. +- A single null measurement is not proof of equivalence. Repeat only close decisions + or unexpected failures that matter; multiple seeds are for quality claims, not + every operational smoke. Keep sample-cache caveats in the report. +- Preserve figures and W&B in qualification. Report end-to-end time as well as + compute-only timing so optimizations cannot hide costs in validation or shutdown. +- Default PR checks should exercise contracts with tiny fixtures. Report test + durations, consolidate duplicated behavior checks, and reserve real-backbone, + GPU, full-scale storage and performance experiments for the relevant changes. + Do not weaken numerical assertions simply to get green CI. +- Continuous benchmarks should use small representative jobs, not large nodes. + Allocation credentials remain separate from candidate code and publishing; + automatic cloud provisioning is not part of this next-run plan. + +## Success criteria and deliberately open questions + +The next campaign should require no hand-edited browser YAML, no reconstruction of +missing commands from chat, and no ambiguity about which stage finished. It should +produce a usable selected-model bundle even if optional PTQ or an upload fails. +Measure operator interventions, allocation time before first productive training, +first-pass/warm throughput, evaluation turnaround, failed/repeated stages and final +artifact size. Set concrete budgets from the next dataset/node plan; the current +record does not support a universal target percentage or preparation-time guarantee. + +Still open: unbiased convergence comparisons across batch sizes/precisions; exact +resume semantics under stochastic training; real-image exported-model parity; +PTQ quality and target performance; deployment threshold policy; regional prior +provenance; whether storage preparation amortizes for the next corpus; and the +published immutable artifact URL. None should be reported as solved by this plan. + +The existing [quantization roadmap](quantization-roadmap.md) remains the specialist +backlog for quantized performance and deployment work. This document supplies the +operational order for the next production campaign, not a competing feature matrix. From ce2a71585f8fd0b8bac15babff5ec3af0c7f11ca Mon Sep 17 00:00:00 2001 From: asgersvenning Date: Fri, 11 Sep 2026 18:26:29 +0200 Subject: [PATCH 28/34] feat(export): optionally expose preclassification embeddings --- mini_trainer/export.py | 3 +++ mini_trainer/modeling/onnx.py | 33 ++++++++++++++++++++++++++++++++- tests/export/test_onnx.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/mini_trainer/export.py b/mini_trainer/export.py index 535f9ed4..28984f60 100644 --- a/mini_trainer/export.py +++ b/mini_trainer/export.py @@ -20,6 +20,7 @@ def main( preprocessing: dict | None = None, model_args: dict | None = None, dynamic_batch: bool = True, + include_embeddings: bool = False, batch_size: int = 2, reference_device: str = "cpu", materialize_int8_training: bool = False, @@ -56,6 +57,7 @@ def main( output, preprocessing=preprocessing, dynamic_batch=dynamic_batch, + include_embeddings=include_embeddings, checkpoint_sha256=checkpoint_hash, reference_device=reference_device, ) @@ -74,6 +76,7 @@ def run(): parser.add_argument("--input-shape", type=int, nargs="+", help="Preprocessed input dimensions, excluding batch (usually C H W).") parser.add_argument("--preprocessing", type=Path, help="JSON deployment recipe for preprocessing outside the graph.") parser.add_argument("--model-args", type=json.loads, help="JSON constructor arguments for the existing architecture loader.") + parser.add_argument("--include-embeddings", action="store_true", help="Also export the linear head preclassification embedding.") parser.add_argument("--static-batch", action="store_false", dest="dynamic_batch", help="Export a fixed batch size.") parser.add_argument("--batch-size", type=int, default=2, help="Example/static batch size (default: 2).") parser.add_argument("--reference-device", default="cpu", help="PyTorch parity device; native INT8 training requires cuda.") diff --git a/mini_trainer/modeling/onnx.py b/mini_trainer/modeling/onnx.py index 29e947cf..c329c2ef 100644 --- a/mini_trainer/modeling/onnx.py +++ b/mini_trainer/modeling/onnx.py @@ -69,6 +69,32 @@ def forward(self, images): return tuple(_flatten(self.model(images))) +class _PredictionEmbeddings(nn.Module): + """Keep the actual prediction forward and expose its head input geometry. + + The backbone runs once. The small eval-only preclassification transform is + replayed from its captured input, without activating training contexts. + """ + + def __init__(self, model): + super().__init__() + self.model = model + heads = [(name, module) for name, module in model.named_modules() if isinstance(module, Classifier)] + if len(heads) != 1 or type(heads[0][1]).__name__ not in {"Classifier", "HierarchicalClassifier"}: + raise ValueError("Embedding export requires one Classifier or HierarchicalClassifier linear head.") + self.head_path = heads[0][0] + self.register_buffer("head_input", None, persistent=False) + heads[0][1].register_forward_pre_hook(self._capture) + + def _capture(self, module, inputs): + self.head_input = inputs[0] + + def forward(self, images): + predictions = self.model(images) + embeddings = self.model.get_submodule(self.head_path).preclassification(self.head_input) + return {"predictions": predictions, "embedding": embeddings} + + def _dependencies(): try: import onnx @@ -111,6 +137,7 @@ def export_onnx( *, preprocessing: dict[str, Any] | None = None, dynamic_batch: bool = True, + include_embeddings: bool = False, verification_inputs: Sequence[torch.Tensor] = (), output_names: Sequence[str] | None = None, opset_version: int = 18, @@ -158,6 +185,8 @@ def export_onnx( translations[torch.ops.mini_trainer.scaled_int8_mm.default] = scaled_int8_mm sample = example_input.detach().cpu().clone() reference = _copy_for_export(model, sample.dtype, reference_device) + if include_embeddings: + reference = _PredictionEmbeddings(reference) if native_int8: # Tensor-subclass decomposition must not assign requires_grad to codes. reference.requires_grad_(False) @@ -180,6 +209,8 @@ def export_onnx( or any(not isinstance(name, str) or not name or name == "images" for name in names) ): raise ValueError("output_names must contain one unique nonempty name per output tensor.") + if include_embeddings and output_names is None: + names[-1] = "embedding" structure = _structure(observed, iter(names)) classifiers = [ {"module": name, "type": class_path(module), "metadata": _json_value(module.metadata)} @@ -262,7 +293,7 @@ def export_onnx( for name, value in zip(names, tensors) ], "output_structure": structure, - "output_semantics": "model_eval_forward", + "output_semantics": "predictions_and_preclassification_embedding" if include_embeddings else "model_eval_forward", "quantized_training_forward": native_int8, "classifiers": classifiers, "preprocessing": {"in_graph": False, "recipe": preprocessing, "requires_configuration": preprocessing is None}, diff --git a/tests/export/test_onnx.py b/tests/export/test_onnx.py index 03554500..bb0c1cce 100644 --- a/tests/export/test_onnx.py +++ b/tests/export/test_onnx.py @@ -225,3 +225,31 @@ def test_efficientnet_v2_s_symmetric_normalized_heads(tmp_path, head): assert {case["batch_size"] for case in manifest["verification"]["cases"]} == {1, 2, 3, 4} assert len(manifest["outputs"]) == (2 if head is HierarchicalClassifier else 1) assert manifest["classifiers"][0]["metadata"]["in_features"] == 1280 + + +@pytest.mark.parametrize("head", [Classifier, HierarchicalClassifier]) +def test_embedding_export_matches_actual_head(tmp_path, head): + import onnxruntime as ort + + from mini_trainer.modeling.context import EmbeddingContext + + model, _ = build_classifier(head, normalized=True) + model.eval() + inputs = torch.randn(2, 3, 5, 5) + captured = [] + hook = model.fc.register_forward_pre_hook(lambda module, args: captured.append(args[0])) + with torch.no_grad(): + predictions = model(inputs) + embedding = model.fc.preclassification(captured[-1]) + hook.remove() + path = export_onnx(model, inputs, tmp_path / "embedding", include_embeddings=True) + options = ort.SessionOptions() + options.intra_op_num_threads = 1 + session = ort.InferenceSession(str(path / "model.onnx"), sess_options=options) + actual = session.run(None, {"images": inputs.numpy()}) + expected = predictions if isinstance(predictions, list) else [predictions] + for result, value in zip(actual, [*expected, embedding], strict=True): + np.testing.assert_allclose(result, value.numpy(), rtol=1e-4, atol=1e-5) + assert session.get_outputs()[-1].name == "embedding" + assert not EmbeddingContext.active() + assert not model.fc._forward_pre_hooks From 1eb2a32954bd9852c7416676f9fb6a61cd74cbe6 Mon Sep 17 00:00:00 2001 From: asgersvenning Date: Fri, 11 Sep 2026 18:27:04 +0200 Subject: [PATCH 29/34] feat(explorer): add browser inference and fixed-map t-SNE insertion --- dev/prototype_space/check_inference.mjs | 24 ++++ dev/wheel_smoke.py | 12 +- docs/production-release-integration.md | 79 +++++++++++++ docs/prototype-browser-implementation.md | 5 +- docs/prototype-browser.md | 106 +++++++++++++++++ docs/prototype-explorer.md | 5 + .../visualization/prototype_space/browser.py | 101 ++++++++++++++++ .../visualization/prototype_space/explore.py | 1 + .../prototype_space/inference.js | 111 ++++++++++++++++++ .../prototype_space/inference_worker.js | 26 ++++ .../prototype_space/insertion.js | 49 ++++++++ .../prototype_space/projection.js | 1 + .../prototype_space/projection.py | 1 + .../visualization/prototype_space/report.html | 7 ++ tests/utils/test_browser_bundle.py | 50 ++++++++ tests/utils/test_browser_insertion.py | 33 ++++++ 16 files changed, 609 insertions(+), 2 deletions(-) create mode 100644 dev/prototype_space/check_inference.mjs create mode 100644 docs/production-release-integration.md create mode 100644 docs/prototype-browser.md create mode 100644 mini_trainer/visualization/prototype_space/browser.py create mode 100644 mini_trainer/visualization/prototype_space/inference.js create mode 100644 mini_trainer/visualization/prototype_space/inference_worker.js create mode 100644 mini_trainer/visualization/prototype_space/insertion.js create mode 100644 tests/utils/test_browser_bundle.py create mode 100644 tests/utils/test_browser_insertion.py diff --git a/dev/prototype_space/check_inference.mjs b/dev/prototype_space/check_inference.mjs new file mode 100644 index 00000000..a9cfcf6f --- /dev/null +++ b/dev/prototype_space/check_inference.mjs @@ -0,0 +1,24 @@ +import {spawn} from 'node:child_process'; +import {mkdtempSync,writeFileSync} from 'node:fs'; +if(!process.env.CHROME_PATH||!process.argv[2]||!process.argv[3])throw Error('Usage: CHROME_PATH=/path/to/chrome node check_inference.mjs URL IMAGE_PATH'); +const chrome=spawn(process.env.CHROME_PATH,['--headless','--no-sandbox','--disable-gpu','--disable-dev-shm-usage','--no-first-run','--disable-background-networking','--remote-debugging-pipe','--user-data-dir='+mkdtempSync('/tmp/browser-inference-check-')],{stdio:['ignore','ignore','ignore','pipe','pipe']}); +let serial=0,buffer=Buffer.alloc(0);const pending=new Map(),errors=[]; +chrome.stdio[4].on('data',chunk=>{buffer=Buffer.concat([buffer,chunk]);let end;while((end=buffer.indexOf(0))!==-1){const msg=JSON.parse(buffer.subarray(0,end));buffer=buffer.subarray(end+1);if(msg.id){const p=pending.get(msg.id);pending.delete(msg.id);msg.error?p.reject(Error(JSON.stringify(msg.error))):p.resolve(msg.result);}else if(msg.method==='Runtime.exceptionThrown')errors.push(msg.params.exceptionDetails);}}); +function send(method,params={},sessionId){const id=++serial;return new Promise((resolve,reject)=>{pending.set(id,{resolve,reject});chrome.stdio[3].write(JSON.stringify({id,method,params,...(sessionId?{sessionId}:{})})+'\0');});} +const timeout=setTimeout(()=>{chrome.kill();process.exitCode=1;console.error('Timeout');},240000); +try{ + const {targetId}=await send('Target.createTarget',{url:'about:blank'});const {sessionId}=await send('Target.attachToTarget',{targetId,flatten:true});const call=(m,p)=>send(m,p,sessionId); + await call('Runtime.enable');await call('Page.enable');await call('Page.navigate',{url:process.argv[2]}); + const evaluate=async expression=>{const r=await call('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true});if(r.exceptionDetails)throw Error(JSON.stringify(r.exceptionDetails));return r.result.value;}; + for(let i=0;i<150;i++){if(await evaluate('typeof data!=="undefined" && !!document.getElementById("inference-load")'))break;await new Promise(r=>setTimeout(r,200));} + if(errors.length)throw Error(JSON.stringify(errors)); + await evaluate('document.getElementById("inference-load").click()'); + for(let i=0;i<600;i++){const status=await evaluate('document.getElementById("inference-status").textContent');if(i%25===0)console.log(status);if(status.startsWith('Model ready'))break;if(/failed|Unsupported|Error|HTTP|no available/.test(status))throw Error(status);await new Promise(r=>setTimeout(r,200));} + const parity=[]; + const {root}=await call('DOM.getDocument');const {nodeId}=await call('DOM.querySelector',{nodeId:root.nodeId,selector:'#inference-image'});await call('DOM.setFileInputFiles',{nodeId,files:[process.argv[3]]}); + for(let i=0;i<600;i++){const status=await evaluate('document.getElementById("inference-status").textContent');if(i%50===0)console.log(status);if(status.startsWith('Complete.'))break;await new Promise(r=>setTimeout(r,200));} + const result=await evaluate(`({status:document.getElementById('inference-status').textContent,rows:document.querySelectorAll('#inference-results li').length,embedding:inferenceQuery?.embedding.length,insertion:inferenceQuery?.insertion,plane:document.getElementById('projection-plane').value})`); + console.log(JSON.stringify({result,errors}));if(process.argv[4])writeFileSync(process.argv[4],JSON.stringify({result,errors},null,2)); + if(!result.status.startsWith('Complete.')||result.rows!==15||!result.embedding||errors.length)throw Error('Browser acceptance failed'); + await send('Browser.close'); +}catch(error){console.error(error);chrome.kill();process.exitCode=1;}finally{clearTimeout(timeout);} diff --git a/dev/wheel_smoke.py b/dev/wheel_smoke.py index 9b83ed78..61a43c3b 100644 --- a/dev/wheel_smoke.py +++ b/dev/wheel_smoke.py @@ -53,7 +53,17 @@ def main(): subprocess.run([str(Path(sys.executable).parent / entry.name), "--help"], check=True, timeout=60, capture_output=True) assets = importlib.resources.files("mini_trainer.visualization.prototype_space") - for name in ("report.html", "launcher.html", "photos.js", "projection.js", "thumbnails.js", "state.js"): + for name in ( + "report.html", + "launcher.html", + "photos.js", + "projection.js", + "thumbnails.js", + "state.js", + "inference.js", + "inference_worker.js", + "insertion.js", + ): assert assets.joinpath(name).read_text(), f"Missing packaged explorer asset: {name}" missing_explorer = subprocess.run( [str(Path(sys.executable).parent / "mt_explore"), "--no-browser"], capture_output=True, text=True, timeout=30 diff --git a/docs/production-release-integration.md b/docs/production-release-integration.md new file mode 100644 index 00000000..8191baea --- /dev/null +++ b/docs/production-release-integration.md @@ -0,0 +1,79 @@ +# Production release storage and deployment integration + +## Existing convention + +The `MAMBO_v2` tag's `mini_trainer/deploy.py` downloaded named checkpoint files +from the public `/Models` share, below `MAMBO/`. Aliases such as `europe`, +`north_europe`, and `full` selected different files. A local weights path bypassed +the download. Retain that useful separation between a model identifier, an +immutable download, and an explicit user override. + +Do not replace the historical MAMBO files: this release uses a different backbone +and has different class coverage. Its final checkpoint identity is +`174b9214bfea2df69e4f5c5d16afd841fec961db4274f3e6bf474cef9cab5e8a`. + +## This publication + +Publish the extracted `global-lepi-production-release-20260911T150236Z` directory +under `/Models`, through SFTP. It contains 1,224 original files and 25,455,849,324 +bytes. The uploaded ZIP's checksum and all original internal file checksums passed. +Remote inventory sizes matched for all files; read-back SHA-256 checks also matched +for the final PyTorch weights, FP32 ONNX external weights and PTQ ONNX graph. +The remote size inventory is not a claim of full remote checksum readback. + +Original `SHA256SUMS` covers the original release. The added `viewer/` distribution +has a separate `SHA256SUMS` and browser-bundle artifact hashes. Keep these scopes +explicit instead of editing the original research-release manifest after the fact. +Local image parity fixtures are excluded from publication. + +The viewer uses a new FP32 export with prediction and embedding outputs. The main +release's original FP32 and PTQ exports remain untouched. A successful PTQ export +or runtime check does not establish PTQ accuracy or a deployment speedup. + +## Browser hosting + +Serve the viewer and its model/runtime bundle from the same public origin. The +existing share serves HTML and `.js` correctly, but did not provide MIME types for +`.mjs` or `.wasm` in the checked responses. The runtime module is copied unchanged +with a `.js` extension and selected through ONNX Runtime's explicit `wasmPaths` +override. Actual Chromium inference through the public share was verified. Avoid +assuming a separate website can fetch these files: the checked responses did not +advertise cross-origin permission. + +A standalone site on a different origin needs an explicit CORS and worker-loading +strategy, or a same-origin static mirror of the bundle. No inference backend or +credential-bearing proxy is required for the current distribution. Model loading +is explicit; images are processed client-side. Keep optional GBIF photo/name +services separate from this guarantee. + +## Rollout increments + +1. **Freeze and catalogue.** Record final URLs, hashes, class order, preprocessing, + output semantics, checkpoint identity, runtime requirements and evidence. Expose + models and documentation as individual downloadable files. Keep release directories + immutable once published; corrections become a new version. +2. **Add a small model registry.** Map human-facing model names to versioned manifests, + not mutable raw checkpoint filenames. Preserve explicit file/URL overrides. Validate + downloaded hashes before promoting a temporary download into a local cache. Keep + model source/version visible in predictions and saved outputs. +3. **Qualify the intended deployment.** Measure real-image parity and throughput on the + target runtime, confirm preprocessing and hierarchy semantics, and evaluate any PTQ + or geographical class filtering separately. Use the held-out in-domain and expert + evaluation protocols; choose thresholds on separate calibration data. Do not infer + production acceptance from the browser smoke test. +4. **Offer a reviewed default.** Introduce a release alias only after acceptance; + preserve rollback to the previous immutable manifest. The qualitative explorer can + link to that same model identity without being the authoritative evaluation tool. +5. **Extend selectively.** Add additional browser/providers or dataset inputs based on + measured benefit. Folder/ZIP inference, offline install, optional cached taxon names, + and cross-origin bundle loading are separate bounded increments. Recomputing global + t-SNE in the browser is unnecessary for inserting an image into this fixed map. + +## Evidence limits + +The first browser implementation was exercised with the final production checkpoint +in Chromium, using CPU WASM, one real-image fixture and synthetic preprocessing +patterns. It is not a Safari/Firefox/WebGPU qualification or a broad accuracy study. +Fixed-map t-SNE insertion is approximate; its measured neighbour retention accompanies +the image. Preserve both the numerical runtime comparison and the end-to-end image +comparison, since matching preprocessed tensors alone does not qualify image decoding. diff --git a/docs/prototype-browser-implementation.md b/docs/prototype-browser-implementation.md index 5654dea2..6cae911d 100644 --- a/docs/prototype-browser-implementation.md +++ b/docs/prototype-browser-implementation.md @@ -1,6 +1,9 @@ # Standalone prototype explorer: implementation plan -Status: ready for goal setting; implementation has not started. This plan defines +Status: the first browser-inference increment is implemented; see +[browser inference](prototype-browser.md). It uses the final production checkpoint, +not the historical epoch-26 reference below. Full browser-side global analysis and +the remaining broader milestones are still pending. This plan defines execution and acceptance for the standalone direction in the [compact roadmap](prototype-explorer-roadmap.md). It does not create an active goal. diff --git a/docs/prototype-browser.md b/docs/prototype-browser.md new file mode 100644 index 00000000..2c33bf1e --- /dev/null +++ b/docs/prototype-browser.md @@ -0,0 +1,106 @@ +# Browser inference in the prototype explorer + +The explorer can run an embedding-enabled ONNX bundle locally using ONNX Runtime +Web 1.24.3 (WASM CPU). User images stay in the browser. The static server serves +model and application files; it performs no inference. + +The first version accepts one opaque RGB JPEG or PNG at a time. Load the model, +choose an image, and inspect the five highest-scoring classes at each hierarchy +level. Species buttons select the corresponding prototype. Parent-level buttons +select a descendant for inspection, not a separate parent prototype. Scores are +softmax probabilities from the actual exported forward, not calibrated reliability. +Rotated EXIF images and transparent inputs are rejected rather than silently using +a different image interpretation. Folder and ZIP ingestion remain future work. + +## Image placement + +When the report contains angular t-SNE, the browser computes angular distances +from the image embedding to all prototype directions. It forms Gaussian affinities +at perplexity 30 over the nearest 90 prototypes, then minimizes conditional KL +against Student-t affinities in the fixed map. Four deterministic starting points +and a bounded, decreasing-loss line search reduce sensitivity to initialization. +Only the image moves. This follows the fixed-reference insertion approach used by +[openTSNE](https://opentsne.readthedocs.io/en/stable/api/index.html#opentsne.TSNEEmbedding.transform), +but is a separate implementation, not a port or an identical objective to a joint +refit. No t-SNE coordinates are assigned merely by selecting the predicted class. + +The purple diamond shows the result. The status reports top-12 angular-neighbour +retention in the displayed neighbourhood and computation time. Low retention is a +limitation of the placement/map, not evidence that the model prediction is wrong. +A saved PCA mean and basis also support exact fixed-transform PCA placement of the +normalized embedding. Neither method changes the prototype coordinates. + +## Authoring a distribution + +Use the matching checkpoint and an ONNX export created with +`mt_export --include-embeddings`. This option preserves default export behaviour +when omitted; the backbone runs once and the small eval preclassification transform +is replayed to expose the embedding. Flat `Classifier` and `HierarchicalClassifier` +heads are supported. Other head families fail explicitly. + +The browser currently supports one explicit preprocessing contract: repository +nearest-neighbour square uint8 resize, torchvision-style bilinear resize and centre +crop, then ImageNet RGB scaling and normalization. Supply this recipe to export +only after verifying it against the checkpoint's evaluation transform: + +```json +{"contract":"nearest-square-uint8-bilinear-center-imagenet-v1","size":384,"resize":438} +``` + +The browser bilinear implementation can differ by one uint8 value from torchvision; +qualify end-to-end image results as well as identical-input runtime parity. This +contract does not cover arbitrary user transforms or dataset preprocessing overrides. + +1. Export the checkpoint with the recipe, embeddings, and normal parity checks. +2. Generate the existing explorer from the same checkpoint (`mt_explore WEIGHTS + --export --output REPORT`). Keep angular t-SNE enabled. +3. Unpack `onnxruntime-web@1.24.3` from npm. Add `LICENSE.txt` from the matching + [ONNX Runtime release](https://github.com/microsoft/onnxruntime/blob/v1.24.3/LICENSE) + to the unpacked package root; npm does not include this file. +4. Package the browser model beside the report: + +```bash +uv run --no-sync python -m mini_trainer.visualization.prototype_space.browser \ + --export ONNX_BUNDLE --weights BEST_PT \ + --runtime UNPACKED_RUNTIME_PACKAGE --output REPORT/browser-model +``` + +5. Serve `REPORT` as static HTTP(S), and open `explorer.html`. The default bundle + URL is `browser-model/manifest.json`. The bundle carries its checkpoint identity, + class order, graph, external tensors, prototype directions, runtime and hashes. + +The package is created atomically and existing destinations are not overwritten. +The runtime package and supplied ONNX export are trusted authoring inputs. Use only +trusted browser-bundle URLs: bundles also contain executable runtime assets. + +## Hosting and limitations + +Serve the explorer and browser bundle from the same origin, with correct HTML, +JavaScript, MJS and WASM MIME types. The initial implementation uses a worker from +the model-bundle origin, so a cross-origin bundle requires more than permitting +model-file CORS alone. No special COOP/COEP headers are needed for its single-thread +WASM configuration. A downloaded HTML retains numerical exploration, but browser +inference requires HTTP(S) serving and the model/runtime files; double-clicking the +HTML is not the complete inference distribution. + +GBIF photos/name lookup retain the existing explorer's network/local-service +requirements. The release's precomputed numerical views do not depend on those +services. The broader standalone-analysis roadmap is still open: this version does +not recompute global Ward/PCA/t-SNE diagnostics in the browser. + +## Browser smoke check + +With a Chromium executable available, the repository check opens the real hosted +URL, loads its model, selects the supplied local image, and requires predictions, +an embedding, and no uncaught page errors: + +```bash +CHROME_PATH=/path/to/chrome node dev/prototype_space/check_inference.mjs \ + https://host/path/explorer.html /path/to/image.jpg /tmp/browser-check.json +``` + +Identical-input ONNX parity and original-image preprocessing comparisons remain +separate numerical qualification checks. The smoke check alone does not establish +accuracy or cross-browser compatibility. The pinned runtime module is distributed +with a `.js` extension because some static servers do not assign `.mjs` a JavaScript +MIME type; its contents are unchanged. diff --git a/docs/prototype-explorer.md b/docs/prototype-explorer.md index 63b357b6..b2784799 100644 --- a/docs/prototype-explorer.md +++ b/docs/prototype-explorer.md @@ -174,3 +174,8 @@ See the [view guide](../dev/prototype_space/README.md#try-the-views), [diagnostic API](prototype-diagnostics.md), and [hyperspherical visualization review](hyperspherical-visualization.md) for metric semantics, display clipping, projection distortion and thumbnail controls. + +## Browser inference + +See [browser inference](prototype-browser.md) for the optional ONNX bundle, local +image predictions, and fixed-map t-SNE/PCA query placement. diff --git a/mini_trainer/visualization/prototype_space/browser.py b/mini_trainer/visualization/prototype_space/browser.py new file mode 100644 index 00000000..16fc31f2 --- /dev/null +++ b/mini_trainer/visualization/prototype_space/browser.py @@ -0,0 +1,101 @@ +"""Package a verified embedding ONNX export for the static browser explorer.""" + +import argparse +import hashlib +import json +import shutil +from pathlib import Path +from tempfile import TemporaryDirectory + + +def package_bundle(export, output, runtime, weights): + """Package the supported RGB nearest-square/ImageNet evaluation contract. + + Runtime is the unpacked, pinned onnxruntime-web npm package. This command + copies assets; it never downloads code or changes a Python environment. + """ + export, output, runtime = Path(export), Path(output), Path(runtime) + manifest = json.loads((export / "manifest.json").read_text()) + if manifest["output_semantics"] != "predictions_and_preclassification_embedding": + raise ValueError("Export with include_embeddings=True first.") + if len(manifest["classifiers"]) != 1: + raise ValueError("Browser bundle requires one classifier.") + metadata = manifest["classifiers"][0]["metadata"] + mapping = metadata["cls2idx"] + levels = [mapping[str(i)] for i in range(len(mapping))] if isinstance(next(iter(mapping.values())), dict) else [mapping] + classes = [[str(name) for name, _ in sorted(level.items(), key=lambda item: item[1])] for level in levels] + shape = manifest["input"]["shape"] + recipe = manifest["preprocessing"]["recipe"] + if ( + manifest["input"]["dtype"] != "float32" + or shape[1] != 3 + or shape[2] != shape[3] + or not recipe + or recipe.get("contract") != "nearest-square-uint8-bilinear-center-imagenet-v1" + or recipe["size"] != shape[2] + or recipe["resize"] < shape[2] + ): + raise ValueError("Bundle requires an explicitly verified nearest-square RGB and ImageNet preprocessing recipe.") + from .explore import load_prototypes + + prototypes, names, _, provenance = load_prototypes(Path(weights)) + if provenance["checkpoint_sha256"] != manifest["source"]["checkpoint_sha256"] or names != classes[0]: + raise ValueError("Prototype checkpoint differs from the ONNX export.") + version = json.loads((runtime / "package.json").read_text())["version"] + if version != "1.24.3": + raise ValueError("This browser bundle supports onnxruntime-web 1.24.3.") + if output.exists(): + raise FileExistsError(output) + # Check every supplied export artifact before creating a distribution. + for name, digest in manifest["artifacts"].items(): + if Path(name).name != name or hashlib.sha256((export / name).read_bytes()).hexdigest() != digest: + raise ValueError(f"Invalid export artifact: {name}") + destination = output + destination.parent.mkdir(parents=True, exist_ok=True) + with TemporaryDirectory(prefix=".browser-bundle-", dir=destination.parent) as temporary: + output = Path(temporary) / "bundle" + output.mkdir() + for name in manifest["artifacts"]: + shutil.copy2(export / name, output / name) + (output / "prototypes.f32").write_bytes(prototypes.numpy().astype("$('inference-status').textContent=message; +$('inference-load').onclick=async()=>{ + inferenceWorker?.terminate();inferenceWorker=null;inferenceQuery=null;inferenceManifest=null; + $('inference-image').disabled=true;++inferenceSequence; + try{ + const url=new URL($('inference-url').value,location.href); + if(!['http:','https:'].includes(url.protocol))throw Error('Use an HTTP or HTTPS browser-bundle manifest URL.'); + inferenceStatus('Loading model metadata…'); + const response=await fetch(url);if(!response.ok)throw Error(`Manifest HTTP ${response.status}`); + const m=await response.json(); + if(m.schema!=='mini-trainer-browser-v1'||m.checkpoint_sha256!==data.metadata.checkpoint_sha256)throw Error('This bundle does not match the displayed checkpoint.'); + if(m.classes[0].length!==data.names.length||m.classes[0].some((name,i)=>name!==data.names[i]))throw Error('Model class order differs from the explorer.'); + const worker=new Worker(new URL('inference_worker.js',url));inferenceWorker=worker; + worker.onerror=event=>inferenceStatus(`Browser worker failed: ${event.message}. Check same-origin hosting and JavaScript MIME types.`); + worker.onmessage=({data:result})=>{ + if(worker!==inferenceWorker)return; + if(result.kind==='ready'){inferenceManifest=m;$('inference-image').disabled=false;inferenceStatus('Model ready. Choose an image; inference runs on this device.');} + if(result.kind==='error'){inferenceStatus(result.message);$('inference-image').disabled=!inferenceManifest;} + if(result.kind==='result'&&result.id===inferenceSequence){showInference(result,m);$('inference-image').disabled=false;} + }; + worker.postMessage({kind:'load',base:url.href,manifest:m,coordinates:data.projections?.['Angular t-SNE']?.coordinates});inferenceStatus('Loading the ONNX model into this browser…'); + }catch(error){inferenceStatus(`${error.message} Cross-origin hosts must allow CORS; this model bundle can be served beside the explorer.`);} +}; +$('inference-cancel').onclick=()=>{++inferenceSequence;inferenceWorker?.terminate();inferenceWorker=null;inferenceManifest=null;$('inference-image').disabled=true;inferenceStatus('Stopped. Load the model to continue.');}; +$('inference-image').onchange=async event=>{ + const file=event.target.files[0];if(!file||!inferenceManifest)return; + const id=++inferenceSequence;$('inference-image').disabled=true;inferenceStatus('Preparing image…'); + try{ + rejectRotatedExif(await file.arrayBuffer()); + const bitmap=await createImageBitmap(file,{imageOrientation:'none',premultiplyAlpha:'none'}); + if(bitmap.width*bitmap.height>40000000){bitmap.close();throw Error('Use an image smaller than 40 megapixels.');} + const canvas=document.createElement('canvas');canvas.width=bitmap.width;canvas.height=bitmap.height; + const ctx=canvas.getContext('2d',{willReadFrequently:true});ctx.drawImage(bitmap,0,0);bitmap.close(); + const pixels=ctx.getImageData(0,0,canvas.width,canvas.height); + const tensor=browserPreprocess(pixels,inferenceManifest); + if(id!==inferenceSequence)return; + inferenceStatus('Running inference locally…');inferenceWorker.postMessage({kind:'infer',id,tensor},[tensor.buffer]); + }catch(error){inferenceStatus(error.message);$('inference-image').disabled=false;} +}; +function browserPreprocess(image,m){ + if(m.preprocessing!=='nearest-square-uint8-bilinear-center-imagenet-v1')throw Error('Unsupported preprocessing contract.'); + const n=m.size,r=m.resize;const square=new Uint8Array(n*n*3); + for(let y=0;y{ + const max=Math.max(...scores),exp=scores.map(x=>Math.exp(x-max)),sum=exp.reduce((a,b)=>a+b,0); + const heading=document.createElement('h3');heading.textContent=`Level ${level}`;container.append(heading); + const list=document.createElement('ol'); + scores.map((score,i)=>({score,i})).sort((a,b)=>b.score-a.score||a.i-b.i).slice(0,5).forEach(({i})=>{ + const row=document.createElement('li'),button=document.createElement('button');button.textContent=`${m.classes[level][i]} · ${(100*exp[i]/sum).toFixed(2)}%`; + button.onclick=()=>{const index=level===0?i:data.groups.findIndex(group=>group[level]===m.classes[level][i]);if(index>=0)selectClass(index);};row.append(button);list.append(row); + });container.append(list); + }); + inferenceQuery={embedding:result.embedding,checkpoint:m.checkpoint_sha256,insertion:result.insertion}; + const plane=result.insertion?'Angular t-SNE':Object.keys(data.projections).find(name=>data.projections[name].transform); + if(plane){$('projection-plane').value=plane;drawProjection();} + inferenceStatus(`Complete. Scores are not calibrated reliability. Purple diamond: ${result.insertion?`fixed-map t-SNE insertion; ${(100*result.insertion.retained_fraction).toFixed(0)}% of top-12 angular neighbours retained, ${result.insertion.milliseconds.toFixed(0)} ms`:'PCA projection'}. Prototype positions remain fixed.`); +} +function drawInferenceQuery(ctx,p,pixels){ + if(!inferenceQuery||inferenceQuery.checkpoint!==data.metadata.checkpoint_sha256)return; + if(!p.transform){ + if(p!==data.projections['Angular t-SNE']||!inferenceQuery.insertion)return; + paintInferencePoint(ctx,inferenceQuery.insertion.coordinates,pixels);return; + } + const e=inferenceQuery.embedding,t=p.transform;if(e.length!==t.mean.length)return; + const norm=t.normalize?Math.hypot(...e):1;const point=[0,0]; + for(let i=0;id.byteLength)throw Error('Malformed JPEG metadata.'); + if(marker===0xffe1&&size>=16&&d.getUint32(p+2)===0x45786966){ + const t=p+8,le=d.getUint16(t)===0x4949,ifd=t+d.getUint32(t+4,le); + if(ifd+2>p+size)throw Error('Malformed EXIF metadata.'); + const count=d.getUint16(ifd,le); + for(let i=0;ip+size)throw Error('Malformed EXIF entries.'); + if(d.getUint16(e,le)===0x112&&d.getUint16(e+8,le)!==1)throw Error('This image has EXIF rotation. Export an unrotated RGB copy for this model.'); + } + }p+=size; + } +} diff --git a/mini_trainer/visualization/prototype_space/inference_worker.js b/mini_trainer/visualization/prototype_space/inference_worker.js new file mode 100644 index 00000000..e7651102 --- /dev/null +++ b/mini_trainer/visualization/prototype_space/inference_worker.js @@ -0,0 +1,26 @@ +/* Local WASM inference. Model files are fetched; user pixels never leave this worker. */ +let session, manifest, prototypes, coordinates; +self.onmessage = async ({data: message}) => { + try { + if(message.kind==='load') { + manifest=message.manifest;coordinates=message.coordinates; + importScripts(new URL('insertion.js',message.base).href); + const response=await fetch(new URL(manifest.prototypes,message.base));if(!response.ok)throw Error('Could not load prototypes.'); + prototypes=new Float32Array(await response.arrayBuffer()); + importScripts(new URL('runtime/ort.wasm.min.js',message.base).href); + ort.env.wasm.numThreads=1; + ort.env.wasm.wasmPaths={mjs:new URL('runtime/ort-wasm-simd-threaded.js',message.base).href,wasm:new URL('runtime/ort-wasm-simd-threaded.wasm',message.base).href}; + session=await ort.InferenceSession.create(new URL(manifest.model,message.base).href,{ + executionProviders:['wasm'],externalData:(manifest.external_data||[]).map(path=>({path,data:new URL(path,message.base).href})) + }); + self.postMessage({kind:'ready'}); + } else if(message.kind==='infer') { + if(!session)throw Error('Load a model first.'); + const result=await session.run({images:new ort.Tensor('float32',message.tensor,[1,3,manifest.size,manifest.size])}); + const outputs=manifest.outputs.map(name=>Array.from(result[name].data)); + const embedding=Array.from(result[manifest.embedding].data); + const insertion=coordinates?insertIntoFixedTSNE(embedding,prototypes,coordinates):null; + self.postMessage({kind:'result',id:message.id,outputs,embedding,insertion}); + } + } catch(error) {self.postMessage({kind:'error',id:message.id,message:String(error.message||error)});} +}; diff --git a/mini_trainer/visualization/prototype_space/insertion.js b/mini_trainer/visualization/prototype_space/insertion.js new file mode 100644 index 00000000..2a3874af --- /dev/null +++ b/mini_trainer/visualization/prototype_space/insertion.js @@ -0,0 +1,49 @@ +/* Fixed-map t-SNE insertion. Only the query moves; all reference coordinates stay fixed. + * Minimize KL(P(query -> prototypes) || normalized Student-t(query -> map)). + * This is an out-of-sample approximation, not a refit of the joint t-SNE map. + */ +function insertionAffinities(distances,perplexity=30){ + const ids=Array.from(distances,(_,i)=>i).sort((a,b)=>distances[a]-distances[b]||a-b).slice(0,Math.min(distances.length,Math.ceil(3*perplexity))); + const target=Math.log(Math.min(perplexity,ids.length));let beta=1,lo=0,hi=Infinity,probabilities=[]; + const minimum=distances[ids[0]]**2; + for(let iteration=0;iteration<80;iteration++){ + probabilities=ids.map(i=>Math.exp(-beta*(distances[i]**2-minimum))); + const total=probabilities.reduce((a,b)=>a+b,0);probabilities=probabilities.map(p=>p/total); + const entropy=-probabilities.reduce((s,p)=>s+(p>0?p*Math.log(p):0),0); + if(Math.abs(entropy-target)<1e-7)break; + if(entropy>target){lo=beta;beta=Number.isFinite(hi)?(lo+hi)/2:beta*2;}else{hi=beta;beta=(lo+hi)/2;} + } + return {ids,probabilities,perplexity:Math.exp(-probabilities.reduce((s,p)=>s+(p?p*Math.log(p):0),0))}; +} +function insertionObjective(point,coordinates,affinities){ + let sum=0,rx=0,ry=0,loss=0,gx=0,gy=0; + for(const q of coordinates){const dx=point[0]-q[0],dy=point[1]-q[1],w=1/(1+dx*dx+dy*dy);sum+=w;rx+=w*w*dx;ry+=w*w*dy;} + affinities.ids.forEach((id,i)=>{const p=affinities.probabilities[i];if(!p)return;const q=coordinates[id],dx=point[0]-q[0],dy=point[1]-q[1],w=1/(1+dx*dx+dy*dy);loss+=p*Math.log(p/w);gx+=p*w*dx;gy+=p*w*dy;}); + return {loss:loss+Math.log(sum),gradient:[2*(gx-rx/sum),2*(gy-ry/sum)]}; +} +function insertIntoFixedTSNE(embedding,prototypes,coordinates,{perplexity=30,iterations=200,k=12}={}){ + const began=performance.now(),n=coordinates.length,d=embedding.length; + if(prototypes.length!==n*d||n<2)throw Error('Prototype dimensions do not match the t-SNE map.'); + const norm=Math.hypot(...embedding);if(!Number.isFinite(norm)||norm===0)throw Error('Invalid image embedding.'); + const distances=new Float64Array(n); + for(let i=0;i{for(let j=0;j<2;j++)centroid[j]+=affinities.probabilities[i]*coordinates[id][j];}); + const starts=[centroid,...affinities.ids.slice(0,3).map(id=>coordinates[id].slice())];let best=null; + for(const start of starts){ + let point=start.slice(),state=insertionObjective(point,coordinates,affinities),rate=10;const initialLoss=state.loss; + for(let iteration=0;iterationv-step*state.gradient[j]),candidate=insertionObjective(next,coordinates,affinities); + if(candidate.loss<=state.loss-1e-4*step*norm*norm){point=next;state=candidate;rate=Math.min(100,step*1.5);accepted=true;break;}step*=.5; + }if(!accepted)break; + } + if(!best||state.loss({i,d:(p[0]-best.coordinates[0])**2+(p[1]-best.coordinates[1])**2})).sort((a,b)=>a.d-b.d||a.i-b.i).slice(0,count).map(p=>p.i); + return {...best,neighbours:original,projected_neighbours:nearby,retained_fraction:original.filter(i=>nearby.includes(i)).length/count,perplexity:affinities.perplexity,milliseconds:performance.now()-began,method:'fixed-map conditional KL insertion'}; +} +if(typeof module!=='undefined')module.exports={insertionAffinities,insertionObjective,insertIntoFixedTSNE}; diff --git a/mini_trainer/visualization/prototype_space/projection.js b/mini_trainer/visualization/prototype_space/projection.js index 02604dfe..107eeec6 100644 --- a/mini_trainer/visualization/prototype_space/projection.js +++ b/mini_trainer/visualization/prototype_space/projection.js @@ -47,6 +47,7 @@ function drawProjection(refreshThumbnails=true) { ctx.font=`${12*pixels}px system-ui`; for(const i of original){const [x,y]=projectionHits[i];ctx.fillStyle='#087e8b';ctx.beginPath();ctx.arc(x,y,4.5*pixels,0,2*Math.PI);ctx.fill();if(!$('map-photos').checked)ctx.fillText(classLabel(i),x+7,y-5);} ctx.fillStyle='#c2344b';ctx.beginPath();ctx.arc(sx,sy,6*pixels,0,2*Math.PI);ctx.fill();ctx.font=`bold ${13*pixels}px system-ui`;if(!$('map-photos').checked)ctx.fillText(classLabel(selected),sx+9,sy+17); + if(typeof drawInferenceQuery==='function')drawInferenceQuery(ctx,p,pixels); const fractions=p.variance_fraction?.map(v=>(v*100).toFixed(2)); const summary=fractions?`Variance retained: ${(100*p.variance_fraction.reduce((a,b)=>a+b,0)).toFixed(2)}% (${p.axes[0]} ${fractions[0]}%, ${p.axes[1]} ${fractions[1]}%). `:`Angular t-SNE · perplexity ${p.parameters.perplexity.toFixed(1)} · seed ${p.parameters.seed}. Map gaps and areas do not measure spherical distances or areas. `; $('projection-quality').textContent=`${summary}Original top-${data.stats.k} neighbours retained among plane top-${data.stats.k}: ${(100*p.mean_retained_fraction).toFixed(1)}% averaged over classes; ${(100*p.retained_fraction[selected]).toFixed(1)}% for selected class. Exact plane-distance ties use class order.`; diff --git a/mini_trainer/visualization/prototype_space/projection.py b/mini_trainer/visualization/prototype_space/projection.py index 4ee0a4ab..e5c85200 100644 --- a/mini_trainer/visualization/prototype_space/projection.py +++ b/mini_trainer/visualization/prototype_space/projection.py @@ -54,6 +54,7 @@ def prototype_projections(weight, neighbours): key = f"PCA {start + 1}–{start + 2}" result[key] = { "coordinates": points.tolist(), + "transform": {"mean": directions.mean(dim=0).tolist(), "basis": axes[:, start : start + 2].tolist(), "normalize": True}, "axes": [f"PC {start + 1}", f"PC {start + 2}"], "variance_fraction": (variances[start : start + 2] / total).tolist() if total > 0 else [0.0, 0.0], "neighbours": near, diff --git a/mini_trainer/visualization/prototype_space/report.html b/mini_trainer/visualization/prototype_space/report.html index 2373b9ad..4b876758 100644 --- a/mini_trainer/visualization/prototype_space/report.html +++ b/mini_trainer/visualization/prototype_space/report.html @@ -81,6 +81,12 @@
+

Try your own image

+

Run this model locally in your browser. Images are not uploaded. Model download and inference may take time on this device.

+ + + +

Load the matching browser bundle to begin.

@@ -263,6 +269,7 @@ Object.keys(cases).forEach(name=>{const o=document.createElement('option');o.value=o.textContent=name;$('case').append(o);});$('case').onchange=setCase;setCase(); + diff --git a/tests/utils/test_browser_bundle.py b/tests/utils/test_browser_bundle.py new file mode 100644 index 00000000..885838fc --- /dev/null +++ b/tests/utils/test_browser_bundle.py @@ -0,0 +1,50 @@ +"""Publishing a browser bundle must preserve model identity and be atomic.""" + +import hashlib +import json + +import pytest + +from mini_trainer.visualization.prototype_space.browser import package_bundle +from tests.utils.test_prototype_launcher import checkpoint + + +def test_browser_bundle_checks_identity_and_cleans_failed_publication(tmp_path): + weights = tmp_path / "weights.pt" + checkpoint(weights) + export = tmp_path / "onnx" + export.mkdir() + (export / "model.onnx").write_bytes(b"fixture graph") + manifest = { + "output_semantics": "predictions_and_preclassification_embedding", + "classifiers": [{"metadata": {"cls2idx": {"alpha": 2, "beta": 0, "gamma": 3, "delta": 1}}}], + "source": {"checkpoint_sha256": hashlib.sha256(weights.read_bytes()).hexdigest()}, + "input": {"dtype": "float32", "shape": ["batch", 3, 384, 384]}, + "outputs": [{"name": "output_0"}, {"name": "embedding"}], + "preprocessing": {"recipe": {"contract": "nearest-square-uint8-bilinear-center-imagenet-v1", "size": 384, "resize": 438}}, + "artifacts": {"model.onnx": hashlib.sha256(b"fixture graph").hexdigest()}, + } + (export / "manifest.json").write_text(json.dumps(manifest)) + runtime = tmp_path / "runtime" + (runtime / "dist").mkdir(parents=True) + (runtime / "package.json").write_text(json.dumps({"version": "1.24.3"})) + for name in ["ort.wasm.min.js", "ort-wasm-simd-threaded.mjs", "ort-wasm-simd-threaded.wasm"]: + (runtime / "dist" / name).write_bytes(b"fixture runtime") + output = tmp_path / "published" + with pytest.raises(FileNotFoundError): + package_bundle(export, output, runtime, weights) + assert not output.exists() + assert not list(tmp_path.glob(".browser-bundle-*")) + (runtime / "LICENSE.txt").write_text("fixture license") + assert package_bundle(export, output, runtime, weights) == output + bundle = json.loads((output / "manifest.json").read_text()) + assert bundle["classes"] == [["beta", "delta", "alpha", "gamma"]] + assert (output / "runtime/ort-wasm-simd-threaded.js").exists() + assert (output / "prototypes.f32").stat().st_size == 4 * 5 * 4 + for name, digest in bundle["artifacts"].items(): + assert hashlib.sha256((output / name).read_bytes()).hexdigest() == digest + with pytest.raises(FileExistsError): + package_bundle(export, output, runtime, weights) + (export / "model.onnx").write_bytes(b"corrupted graph") + with pytest.raises(ValueError, match="Invalid export artifact"): + package_bundle(export, tmp_path / "corrupt", runtime, weights) diff --git a/tests/utils/test_browser_insertion.py b/tests/utils/test_browser_insertion.py new file mode 100644 index 00000000..1a2c18f1 --- /dev/null +++ b/tests/utils/test_browser_insertion.py @@ -0,0 +1,33 @@ +"""Numerical checks for client-side fixed-map insertion, without a browser runtime.""" + +import json +import shutil +import subprocess +from pathlib import Path + +import numpy as np +import pytest + +SCRIPT = Path(__file__).parents[2] / "mini_trainer/visualization/prototype_space/insertion.js" + + +@pytest.mark.skipif(shutil.which("node") is None, reason="Node is required for the browser numerical implementation") +def test_fixed_map_insertion_and_gradient(): + runner = r""" +const {insertionAffinities,insertionObjective,insertIntoFixedTSNE}=require(process.argv[1]); +const points=Array.from({length:100},(_,i)=>[10*Math.cos(i*2*Math.PI/100),10*Math.sin(i*2*Math.PI/100)]); +const prototypes=Float32Array.from(points.flatMap(([x,y])=>[x/10,y/10,0])); +const before=JSON.stringify(points),aff=insertionAffinities(points.map(([x,y])=>Math.hypot(x-8,y-2)),10); +const q=[8.2,2.3],state=insertionObjective(q,points,aff),epsilon=1e-5; +const finite=q.map((_,j)=>{const a=q.slice(),b=q.slice();a[j]+=epsilon;b[j]-=epsilon; +return (insertionObjective(a,points,aff).loss-insertionObjective(b,points,aff).loss)/(2*epsilon);}); +const result=insertIntoFixedTSNE([1,.01,0],prototypes,points,{perplexity:5}); +console.log(JSON.stringify({gradient:state.gradient,finite,result,unchanged:before===JSON.stringify(points)})); +""" + output = subprocess.run(["node", "-e", runner, str(SCRIPT)], capture_output=True, text=True, check=True) + report = json.loads(output.stdout) + np.testing.assert_allclose(report["gradient"], report["finite"], rtol=1e-6, atol=1e-8) + assert report["unchanged"] + assert report["result"]["kl"] <= report["result"]["initial_kl"] + assert report["result"]["retained_fraction"] >= 0.9 + assert np.isfinite(report["result"]["coordinates"]).all() From 268d294c5ede8b0193a275e50a3ee3ada1be7825 Mon Sep 17 00:00:00 2001 From: asgersvenning Date: Fri, 11 Sep 2026 18:56:08 +0200 Subject: [PATCH 30/34] agent: keep validation proportional to change risk --- AGENTS.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 40f50e18..c34819dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,18 @@ Complete a bounded, validated increment before moving to the next priority. - Slow backbone tests require `RUN_SLOW_TESTS=1` and may need model downloads. The compatibility runner can update the backbone blacklist; use it only when that mutation is part of the task. +- Be economical with validation: choose the smallest set of checks that covers the + changed behavior and credible regressions, while satisfying the requirements above. + Before an expensive suite or benchmark, identify the unresolved question it answers. +- Reuse passing evidence for unchanged code and environments. Batch related edits + before expensive checks; do not rerun the same suite for documentation changes or + automatically repeat focused checks already covered by a passing broader run. + Repeat or broaden checks when new changes, failures, integration conflicts or a + concrete unresolved risk justify it, not merely for additional reassurance. +- Add tests for meaningful behavior and failure modes, not assertions that mirror + implementation details or low-impact presentation changes. Prefer existing + coverage and a focused browser/manual check where appropriate. Keep required CI + gates intact; report the limits of focused validation rather than implying full coverage. - Report checks run, failures, skips, and limits honestly. Do not weaken checks or alter expected results merely to make a refactor pass. From 1eb3efe5378ae90d9cd1ebab213564ad5f556c7f Mon Sep 17 00:00:00 2001 From: asgersvenning Date: Fri, 11 Sep 2026 18:56:12 +0200 Subject: [PATCH 31/34] feat(explorer): add shared browser GBIF metadata client --- dev/prototype_space/check_gbif.cjs | 30 ++++++ .../visualization/prototype_space/gbif.js | 91 +++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 dev/prototype_space/check_gbif.cjs create mode 100644 mini_trainer/visualization/prototype_space/gbif.js diff --git a/dev/prototype_space/check_gbif.cjs b/dev/prototype_space/check_gbif.cjs new file mode 100644 index 00000000..7a72cce9 --- /dev/null +++ b/dev/prototype_space/check_gbif.cjs @@ -0,0 +1,30 @@ +/* Deterministic transport checks; no public API calls. */ +const assert=require('node:assert/strict'); +const {BrowserGBIF}=require('../../mini_trainer/visualization/prototype_space/gbif.js'); +(async()=>{ + let calls=0,active=0,peak=0; + const client=new BrowserGBIF({concurrency:2,fetcher:async url=>{ + calls++;active++;peak=Math.max(peak,active); + await new Promise(resolve=>setTimeout(resolve,5));active--; + return {ok:true,json:async()=>({key:Number(url.split('/').pop()),canonicalName:'Example'})}; + }}); + assert.equal(client.seed({schema:'mini-trainer-gbif-v1',taxa:{'42':{key:42,canonicalName:'Cached species'},'43':{key:99}}}),1); + assert.equal((await client.name('42')).display_name,'Cached species');assert.equal(calls,0); + const [a,b]=await Promise.all([client.name('123'),client.name('123')]); + assert.deepEqual(a,b);assert.equal(calls,1);await client.name('123');assert.equal(calls,1); + await Promise.all(['1','2','3','4'].map(id=>client.name(id)));assert.equal(peak,2); + await assert.rejects(client.name('not-a-taxon')); + const album=BrowserGBIF.albumFromRecords('1',{acceptedKey:2},[ + {key:10,speciesKey:9,media:[{type:'StillImage',identifier:'https://example.org/wrong.jpg'}]}, + {key:11,speciesKey:2,media:[{type:'StillImage',identifier:'javascript:alert(1)'},{type:'StillImage',identifier:'https://example.org/photo.jpg',creator:'Author',license:'CC BY'}]} + ]); + assert.equal(album.class_id,'1');assert.equal(album.accepted_id,'2');assert.equal(album.photos.length,1); + assert.equal(album.photos[0].creator,'Author');assert.equal(album.photos[0].license,'CC BY'); + let release; + const slow=new BrowserGBIF({fetcher:()=>new Promise(resolve=>release=resolve)}); + const pending=slow.name('1');slow.reset();release({ok:true,json:async()=>({key:1})}); + await assert.rejects(pending,{name:'AbortError'});assert.equal(slow.cache.size,0); + const limited=new BrowserGBIF({fetcher:async()=>({ok:false,status:429})}); + await assert.rejects(limited.name('1'),/request limit/);assert.equal(limited.pending.size,0); + console.log('GBIF checks passed: deduplication, cache, concurrency, IDs, taxonomy, URLs, attribution, cancellation, rate limit.'); +})().catch(error=>{console.error(error);process.exitCode=1;}); diff --git a/mini_trainer/visualization/prototype_space/gbif.js b/mini_trainer/visualization/prototype_space/gbif.js new file mode 100644 index 00000000..e518a479 --- /dev/null +++ b/mini_trainer/visualization/prototype_space/gbif.js @@ -0,0 +1,91 @@ +/* Shared browser-only GBIF metadata. Original model IDs never change. */ +class BrowserGBIF { + constructor({fetcher=globalThis.fetch.bind(globalThis),concurrency=4,timeout=12000}={}) { + this.fetcher=fetcher;this.concurrency=concurrency;this.timeout=timeout; + this.cache=new Map();this.pending=new Map();this.queue=[];this.active=0; + this.controller=new AbortController(); + } + seed(snapshot){ + if(snapshot?.schema!=='mini-trainer-gbif-v1'||!snapshot.taxa||typeof snapshot.taxa!=='object')throw Error('Invalid GBIF snapshot'); + let count=0; + for(const [id,taxon] of Object.entries(snapshot.taxa)){ + if(!/^\d+$/.test(id)||!taxon||String(taxon.key)!==id)continue; + this.cache.set('species/'+id,taxon);count++; + } + return count; + } + reset(){ + this.controller.abort();this.controller=new AbortController(); + this.cache.clear(); + } + request(path){ + const signal=this.controller.signal,key=path; + const existing=this.pending.get(key); + if(this.cache.has(key))return Promise.resolve(this.cache.get(key)); + if(existing&&existing.signal===signal)return existing.promise; + const job={key,signal}; + job.promise=new Promise((resolve,reject)=>Object.assign(job,{resolve,reject})); + this.pending.set(key,job);this.queue.push(job);this.pump();return job.promise; + } + pump(){ + while(this.active{ + try{ + const signal=AbortSignal.any([job.signal,AbortSignal.timeout(this.timeout)]); + const response=await this.fetcher('https://api.gbif.org/v1/'+job.key,{signal,credentials:'omit'}); + if(!response.ok)throw Error(response.status===429?'GBIF request limit reached; retry later.':`GBIF HTTP ${response.status}`); + const value=await response.json(); + if(job.signal.aborted)throw new DOMException('Lookup cancelled','AbortError'); + if(!value||typeof value!=='object'||Array.isArray(value))throw Error('Invalid GBIF response'); + this.cache.set(job.key,value);job.resolve(value); + }catch(error){job.reject(error);} + finally{this.active--;if(this.pending.get(job.key)===job)this.pending.delete(job.key);this.pump();} + })(); + } + } + async taxon(id){ + id=String(id);if(!/^\d+$/.test(id))throw Error('Not a GBIF taxon ID'); + const value=await this.request('species/'+id); + if(String(value.key)!==id)throw Error('GBIF returned a different taxon'); + return value; + } + async name(id){ + const taxon=await this.taxon(id); + return {class_id:String(id),display_name:taxon.canonicalName||taxon.scientificName||String(id)}; + } + async album(id){ + id=String(id);const generation=this.controller.signal; + const taxon=await this.taxon(id); + if(generation.aborted)throw new DOMException('Lookup cancelled','AbortError'); + const records=await this.request('occurrence/search?taxonKey='+id+'&mediaType=StillImage&limit=20'); + if(generation.aborted)throw new DOMException('Lookup cancelled','AbortError'); + if(!Array.isArray(records.results))throw Error('Invalid GBIF occurrence response'); + return BrowserGBIF.albumFromRecords(id,taxon,records.results); + } + static webURL(value){ + if(typeof value!=='string')return false; + try{return ['https:','http:'].includes(new URL(value).protocol);}catch{return false;} + } + static albumFromRecords(id,taxon,records){ + const accepted=String(taxon.acceptedKey||id),photos=[],seen=new Set(); + for(const record of records){ + if(!record||!/^\d+$/.test(String(record.key)))continue; + const members=['taxonKey','acceptedTaxonKey','speciesKey','genusKey','familyKey','orderKey','classKey','phylumKey','kingdomKey'].map(key=>String(record[key])); + if(!members.includes(id)&&!members.includes(accepted))continue; + for(const media of Array.isArray(record.media)?record.media:[]){ + if(!media||media.type!=='StillImage'||!this.webURL(media.identifier)||seen.has(media.identifier))continue; + seen.add(media.identifier); + photos.push({occurrence_id:String(record.key),image_path:media.identifier,original:media.identifier, + source:this.webURL(media.references)?media.references:'https://www.gbif.org/occurrence/'+record.key, + creator:media.creator||media.rightsHolder||'Creator not supplied',license:media.license||'Image license not supplied',scientific_name:record.scientificName||''}); + if(photos.length===6)break; + } + if(photos.length===6)break; + } + return {class_id:id,accepted_id:accepted,display_name:taxon.canonicalName||taxon.scientificName||id,taxonomic_status:taxon.taxonomicStatus,photos,selection:'GBIF class examples'}; + } +} +if(typeof module!=='undefined')module.exports={BrowserGBIF}; From c3c41340ce4845b9f49eba43ba13d04d5661882f Mon Sep 17 00:00:00 2001 From: asgersvenning Date: Fri, 11 Sep 2026 19:15:18 +0200 Subject: [PATCH 32/34] feat(explorer): unify portable GBIF settings and prediction views --- dev/prototype_space/check_browser.mjs | 45 +++++------ dev/prototype_space/check_gbif.cjs | 7 ++ dev/prototype_space/check_inference.mjs | 16 +++- dev/prototype_space/check_portable.mjs | 78 ++++++++++++++++++ dev/prototype_space/prepare_gbif.py | 68 ++++++++++++++++ dev/wheel_smoke.py | 1 + .../visualization/prototype_space/explore.py | 6 ++ .../prototype_space/inference.js | 38 +++++++-- .../visualization/prototype_space/photos.js | 28 +++---- .../visualization/prototype_space/report.html | 79 +++++++++++++++---- .../visualization/prototype_space/state.js | 12 +-- .../prototype_space/thumbnails.js | 6 +- tests/utils/test_prototype_exploration.py | 16 ++++ 13 files changed, 325 insertions(+), 75 deletions(-) create mode 100644 dev/prototype_space/check_portable.mjs create mode 100644 dev/prototype_space/prepare_gbif.py diff --git a/dev/prototype_space/check_browser.mjs b/dev/prototype_space/check_browser.mjs index 70b28606..887059b3 100644 --- a/dev/prototype_space/check_browser.mjs +++ b/dev/prototype_space/check_browser.mjs @@ -17,8 +17,9 @@ try{ const {targetId}=await send('Target.createTarget',{url:'about:blank'});const {sessionId}=await send('Target.attachToTarget',{targetId,flatten:true}); const call=(m,p)=>send(m,p,sessionId);await call('Page.enable');await call('Runtime.enable');await call('Emulation.setDeviceMetricsOverride',{width:1440,height:1100,deviceScaleFactor:1,mobile:false}); await call('Page.navigate',{url:process.argv[2].startsWith('http')?process.argv[2]:pathToFileURL(process.argv[2]).href}); - let ready=false;for(let i=0;i<100;i++){const r=await call('Runtime.evaluate',{expression:'document.querySelectorAll("#neighbours tr").length',returnByValue:true});if(r.result.value>0){ready=true;break;}await new Promise(r=>setTimeout(r,100));} + let ready=false;for(let i=0;i<100;i++){const r=await call('Runtime.evaluate',{expression:'typeof captureViewerState === "function" ? document.querySelectorAll("#neighbours tr").length : 0',returnByValue:true});if(r.result.value>0){ready=true;break;}await new Promise(r=>setTimeout(r,100));} if(!ready)throw Error('Inspector did not render: '+JSON.stringify(errors)); + await call('Runtime.evaluate',{expression:"new Promise(resolve=>{setWorkspaceView('all');requestAnimationFrame(()=>requestAnimationFrame(resolve));})",awaitPromise:true}); const result=await call('Runtime.evaluate',{expression:`(()=>{const checks=[];function check(v,s){if(!v)throw Error(s);checks.push(s)} check(document.querySelectorAll('#stats .stat').length===4,'production summary'); check(projectionHits.length===data.names.length,'projection contains every class'); @@ -33,8 +34,8 @@ try{ const oldScale=projectionView.scale;canvas.dispatchEvent(new WheelEvent('wheel',{clientX:bounds.left+bounds.width/2,clientY:bounds.top+bounds.height/2,deltaY:-150,cancelable:true}));check(projectionView.scale>oldScale,'projection zoom'); const oldX=projectionView.x;projectionDrag={point:projectionMouse({clientX:bounds.left+100,clientY:bounds.top+100}),view:{...projectionView},moved:false};canvas.onpointermove({clientX:bounds.left+150,clientY:bounds.top+100});canvas.onpointerup({pointerId:1});check(projectionView.x!==oldX,'projection pan'); document.getElementById('projection-reset').click(); - const target=projectionHits.findIndex(([x,y],i)=>i!==selected&&x>20&&x<1180&&y>20&&y<580&&projectionHit([x,y])===i);check(target>=0,'projected class is selectable'); - const [px,py]=projectionHits[target];const event={button:0,pointerId:1,clientX:bounds.left+px*bounds.width/1200,clientY:bounds.top+py*bounds.height/600};projectionDrag={point:projectionMouse(event),view:{...projectionView},moved:false};canvas.onpointerup(event); + const target=projectionHits.findIndex(([x,y],i)=>i!==selected&&x>20&&x<1180&&y>20&&y=0,'projected class is selectable'); + const [px,py]=projectionHits[target];const event={button:0,pointerId:1,clientX:bounds.left+px*bounds.width/1200,clientY:bounds.top+py*bounds.height/projectionHeight};projectionDrag={point:projectionMouse(event),view:{...projectionView},moved:false};canvas.onpointerup(event); check(selected===target&&document.getElementById('selected-name').textContent===data.names[target],'projection selection updates inspector'); check(document.querySelectorAll('#neighbours tr').length===data.stats.k,'production neighbours'); @@ -63,9 +64,9 @@ try{ await call('Runtime.evaluate',{expression:'document.getElementById("global-metric").closest(".panel").scrollIntoView()'});const globalShot=await call('Page.captureScreenshot',{format:'png'});writeFileSync(join(output,'matrix.png'),Buffer.from(globalShot.data,'base64'));const report={checks:result.result.value,javascript_errors:errors.length};writeFileSync(join(output,'browser-check.json'),JSON.stringify(report,null,2)+'\n');console.log(JSON.stringify(report)); const photos=await call('Runtime.evaluate',{expression:`(async()=>{ const checks=[];const check=(value,label)=>{if(!value)throw Error(label);checks.push(label)}; - const originalFetch=window.fetch; + const originalFetch=window.fetch,originalAlbum=gbifClient.album; const pixel='data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; - window.fetch=async url=>({ok:true,json:async()=>url==='/api/health'?{photo_api:true}:{class_id:url.split('/').pop(),accepted_id:url.split('/').pop(),display_name:'Fixture class',photos:[0,1].map(i=>({image_path:pixel,creator:'Fixture photographer',license:'Fixture media license',source:'https://www.gbif.org',occurrence_id:'1'}))}}); + gbifClient.album=async id=>({class_id:id,accepted_id:id,display_name:'Fixture class',photos:[0,1].map(i=>({image_path:pixel,creator:'Fixture photographer',license:'Fixture media license',source:'https://www.gbif.org',occurrence_id:'1'}))});$('class-id-namespace').value='gbif';$('class-id-namespace').onchange(); document.getElementById('photo-enabled').checked=true;await renderPhotos(); check(document.querySelectorAll('.photo-card').length===data.stats.k+1,'photo neighbourhood cards'); check(document.querySelector('.photo-card img').naturalWidth===1,'photo image loaded'); @@ -186,22 +187,22 @@ try{ - const currentBox=mapThumbLayout[0];check(mapThumbnailHit([currentBox.x*1200/projectionCanvas.getBoundingClientRect().width,currentBox.y*600/projectionCanvas.getBoundingClientRect().height])===currentBox.id,'displaced thumbnail hit testing'); + const currentBox=mapThumbLayout[0];check(mapThumbnailHit([currentBox.x*1200/projectionCanvas.getBoundingClientRect().width,currentBox.y*projectionHeight/projectionCanvas.getBoundingClientRect().height])===currentBox.id,'displaced thumbnail hit testing'); document.getElementById('map-photo-labels').checked=true;document.getElementById('map-photo-push').checked=false;document.getElementById('map-photo-overlap').value='0';document.getElementById('map-photo-density').value='40'; scheduleMapThumbnails();clearTimeout(mapThumbTimer);await renderMapThumbnails(); check([...document.querySelectorAll('.map-thumb')].every(b=>b.querySelector('span'))&&document.querySelectorAll('.map-thumb-tether').length===0,'label and fixed-position modes restore'); document.getElementById('map-photos').checked=false;scheduleMapThumbnails();check(document.querySelectorAll('.map-thumb').length===0,'map thumbnail toggle clears images'); - const fixtureFetch=window.fetch;let release; - window.fetch=()=>new Promise(resolve=>{release=()=>resolve({ok:true,json:async()=>({photo_api:true})})}); + const fixtureAlbum=gbifClient.album;photoAlbums.clear(); + gbifClient.album=async id=>{await new Promise(r=>setTimeout(r,40));return fixtureAlbum(id);}; document.getElementById('map-photos').checked=true;scheduleMapThumbnails();clearTimeout(mapThumbTimer);const pendingMap=renderMapThumbnails(); - document.getElementById('map-photos').checked=false;scheduleMapThumbnails();release();await pendingMap; + document.getElementById('map-photos').checked=false;scheduleMapThumbnails();await pendingMap; check(document.querySelectorAll('.map-thumb').length===0,'cancelled thumbnail load stays hidden'); - let syntheticRequests=0;window.fetch=async()=>{syntheticRequests++;throw Error('Unexpected synthetic lookup')}; + let syntheticRequests=0;gbifClient.album=async()=>{syntheticRequests++;throw Error('Unexpected synthetic lookup')}; document.getElementById('case').value='Algebraic edge cases';document.getElementById('case').dispatchEvent(new Event('change'));document.getElementById('map-photos').checked=true;scheduleMapThumbnails(); check(syntheticRequests===0&&document.getElementById('map-photo-status').textContent.includes('Synthetic'),'synthetic map skips photo requests'); - document.getElementById('map-photos').checked=false;scheduleMapThumbnails();window.fetch=fixtureFetch; - window.fetch=originalFetch; + document.getElementById('map-photos').checked=false;scheduleMapThumbnails();gbifClient.album=fixtureAlbum; + window.fetch=originalFetch;gbifClient.album=originalAlbum; document.getElementById('case').value='Production checkpoint';document.getElementById('case').dispatchEvent(new Event('change')); return checks; })()`,awaitPromise:true,returnByValue:true}); @@ -218,7 +219,7 @@ try{ const rect=projectionCanvas.getBoundingClientRect(); check(! $('inspector').hidden&&!$('photo-panel').hidden&&$('tree-panel').hidden,'projection groups related panels'); check(rect.width>innerWidth*.8&&rect.height>=300,'projection uses viewport width and height'); - check(rect.top===0&&rect.left===0&&Math.abs(rect.height-innerHeight)<1&&Math.abs(rect.width-document.documentElement.clientWidth)<1,'projection fills the viewport'); + const nav=$('activity-nav').getBoundingClientRect();check(Math.abs(rect.top-nav.bottom)<1&&rect.left===0&&Math.abs(rect.bottom-innerHeight)<1&&Math.abs(rect.width-document.documentElement.clientWidth)<1,'projection fills available viewport below navigation'); $('workspace-controls').open=true;$('projection-controls').open=true;await new Promise(r=>setTimeout(r,60)); const expanded=projectionCanvas.getBoundingClientRect(); check(expanded.width===rect.width&&expanded.height===rect.height&&expanded.top===rect.top,'floating controls do not resize map'); @@ -265,15 +266,15 @@ try{ check(!$('score-note').hidden&&$('local-scale').textContent.includes('Upper-tail'),'probability reference and logarithmic colours disclosed'); check($('profile').textContent.includes('Upper-tail'),'profile probability axis'); $('score-display').value='z';$('score-display').onchange();check($('score-column').textContent==='z','z display restores'); - const original=window.fetch,ids=[selected,...data.neighbours[selected]],pending=[];let inFlight=0,peak=0; + const original=gbifClient.name,ids=[selected,...data.neighbours[selected]],pending=[];let inFlight=0,peak=0; importedNames.delete(data);resolvedNames.clear();nameFailures.clear(); - window.fetch=async url=>{if(String(url).startsWith('/api/taxon/')){inFlight++;peak=Math.max(peak,inFlight);pending.push(url);await new Promise(r=>setTimeout(r,30));inFlight--;return {ok:true,json:async()=>({class_id:String(url).split('/').pop(),display_name:'Async taxon '+String(url).split('/').pop()})};}return original(url);}; - $('gbif-names').checked=true;requestClassNames(); + gbifClient.name=async id=>{inFlight++;peak=Math.max(peak,inFlight);pending.push(id);await new Promise(r=>setTimeout(r,30));inFlight--;return {class_id:id,display_name:'Async taxon '+id};}; + $('class-id-namespace').value='gbif';requestClassNames(); for(let i=0;i<60&&!resolvedNames.size;i++)await new Promise(r=>setTimeout(r,30)); await new Promise(r=>setTimeout(r,200)); - check(resolvedNames.size>0&&pending.every(url=>url.startsWith('/api/taxon/')),'asynchronous names need no photo lookup'); + check(resolvedNames.size>0&&pending.every(id=>String(Number(id))===id),'asynchronous names need no photo lookup'); check(peak===1&&pending.length<30,'name lookup is bounded and prioritizes a small visible set'); - $('class-names').checked=false;$('gbif-names').checked=false;await new Promise(r=>setTimeout(r,100));window.fetch=original;refreshClassLabels(); + $('class-names').checked=false;$('class-id-namespace').value='generic';await new Promise(r=>setTimeout(r,100));gbifClient.name=original;refreshClassLabels(); check($('selected-name').textContent===data.names[selected],'IDs restore without losing selected class'); const rect=projectionCanvas.getBoundingClientRect();check(projectionCanvas.width===Math.round(rect.width*devicePixelRatio)&&projectionCanvas.height===Math.round(rect.height*devicePixelRatio),'canvas backing matches physical screen pixels'); check(rect.height>=300,'focused map retains vertical workspace'); @@ -299,11 +300,11 @@ try{ check(rejected,'state from another model is rejected'); rejected=false;try{applyViewerState({...saved,view:{x:0,y:0,scale:-1}});}catch{rejected=true;} check(rejected,'malformed saved geometry is rejected'); - saveViewerState();check(JSON.parse(localStorage.getItem('prototype-view:'+viewerIdentity())).version===1,'state persists in browser storage'); + saveViewerState();check(JSON.parse(localStorage.getItem('prototype-view:'+viewerIdentity())).version===2,'state persists in browser storage'); const storageWrite=Storage.prototype.setItem;Storage.prototype.setItem=()=>{throw Error('quota');};saveViewerState();Storage.prototype.setItem=storageWrite; check($('viewer-state-status').textContent.includes('unavailable'),'storage failure leaves viewer usable'); - const metadataFetch=window.fetch;let requests=0,active=0,peakMetadata=0; - window.fetch=async url=>{requests++;active++;peakMetadata=Math.max(peakMetadata,active);await new Promise(r=>setTimeout(r,40));active--;return {ok:true,json:async()=>({class_id:String(url).split('/').pop(),photos:[]})};}; + const metadataFetch=gbifClient.album;$('class-id-namespace').value='gbif';let requests=0,active=0,peakMetadata=0; + gbifClient.album=async id=>{requests++;active++;peakMetadata=Math.max(peakMetadata,active);await new Promise(r=>setTimeout(r,40));active--;return {class_id:id,photos:[]};}; const obsolete=new AbortController(),current=new AbortController(); const first=referenceAlbum('fixture-shared',obsolete.signal);obsolete.abort(); const second=referenceAlbum('fixture-shared',current.signal);await Promise.all([first,second]); @@ -311,7 +312,7 @@ try{ await Promise.all(Array.from({length:8},(_,i)=>referenceAlbum('fixture-bound-'+i,current.signal))); check(peakMetadata<=4,'metadata requests are globally bounded'); const cachedRequests=requests;await referenceAlbum('fixture-shared',current.signal); - check(requests===cachedRequests,'resolved metadata is reused without fetching');window.fetch=metadataFetch; + check(requests===cachedRequests,'resolved metadata is reused without fetching');gbifClient.album=metadataFetch; return checks; diff --git a/dev/prototype_space/check_gbif.cjs b/dev/prototype_space/check_gbif.cjs index 7a72cce9..7097d48e 100644 --- a/dev/prototype_space/check_gbif.cjs +++ b/dev/prototype_space/check_gbif.cjs @@ -26,5 +26,12 @@ const {BrowserGBIF}=require('../../mini_trainer/visualization/prototype_space/gb await assert.rejects(pending,{name:'AbortError'});assert.equal(slow.cache.size,0); const limited=new BrowserGBIF({fetcher:async()=>({ok:false,status:429})}); await assert.rejects(limited.name('1'),/request limit/);assert.equal(limited.pending.size,0); + const offline=new BrowserGBIF({fetcher:async()=>{throw Error('Offline');}});await assert.rejects(offline.name('1'),/Offline/); + const malformed=new BrowserGBIF({fetcher:async()=>({ok:true,json:async()=>null})});await assert.rejects(malformed.name('1'),/Invalid GBIF response/); + const wrong=new BrowserGBIF({fetcher:async()=>({ok:true,json:async()=>({key:2})})});await assert.rejects(wrong.name('1'),/different taxon/); + assert.deepEqual(BrowserGBIF.albumFromRecords('1',{key:1},[]).photos,[]); + assert.throws(()=>client.seed({schema:'unsupported',taxa:{}}),/snapshot/); + const timed=new BrowserGBIF({timeout:5,fetcher:async(url,{signal})=>new Promise((resolve,reject)=>signal.addEventListener('abort',()=>reject(signal.reason)))}); + const keepAlive=setTimeout(()=>{},100);await assert.rejects(timed.name('1'),{name:'TimeoutError'});clearTimeout(keepAlive); console.log('GBIF checks passed: deduplication, cache, concurrency, IDs, taxonomy, URLs, attribution, cancellation, rate limit.'); })().catch(error=>{console.error(error);process.exitCode=1;}); diff --git a/dev/prototype_space/check_inference.mjs b/dev/prototype_space/check_inference.mjs index a9cfcf6f..d3a77d13 100644 --- a/dev/prototype_space/check_inference.mjs +++ b/dev/prototype_space/check_inference.mjs @@ -1,5 +1,5 @@ import {spawn} from 'node:child_process'; -import {mkdtempSync,writeFileSync} from 'node:fs'; +import {mkdtempSync,writeFileSync,mkdirSync} from 'node:fs'; if(!process.env.CHROME_PATH||!process.argv[2]||!process.argv[3])throw Error('Usage: CHROME_PATH=/path/to/chrome node check_inference.mjs URL IMAGE_PATH'); const chrome=spawn(process.env.CHROME_PATH,['--headless','--no-sandbox','--disable-gpu','--disable-dev-shm-usage','--no-first-run','--disable-background-networking','--remote-debugging-pipe','--user-data-dir='+mkdtempSync('/tmp/browser-inference-check-')],{stdio:['ignore','ignore','ignore','pipe','pipe']}); let serial=0,buffer=Buffer.alloc(0);const pending=new Map(),errors=[]; @@ -10,9 +10,9 @@ try{ const {targetId}=await send('Target.createTarget',{url:'about:blank'});const {sessionId}=await send('Target.attachToTarget',{targetId,flatten:true});const call=(m,p)=>send(m,p,sessionId); await call('Runtime.enable');await call('Page.enable');await call('Page.navigate',{url:process.argv[2]}); const evaluate=async expression=>{const r=await call('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true});if(r.exceptionDetails)throw Error(JSON.stringify(r.exceptionDetails));return r.result.value;}; - for(let i=0;i<150;i++){if(await evaluate('typeof data!=="undefined" && !!document.getElementById("inference-load")'))break;await new Promise(r=>setTimeout(r,200));} + for(let i=0;i<150;i++){if(await evaluate('typeof captureViewerState==="function" && !!document.getElementById("inference-load")'))break;await new Promise(r=>setTimeout(r,200));} if(errors.length)throw Error(JSON.stringify(errors)); - await evaluate('document.getElementById("inference-load").click()'); + await evaluate('setWorkspaceView("predict");document.getElementById("inference-load").click()'); for(let i=0;i<600;i++){const status=await evaluate('document.getElementById("inference-status").textContent');if(i%25===0)console.log(status);if(status.startsWith('Model ready'))break;if(/failed|Unsupported|Error|HTTP|no available/.test(status))throw Error(status);await new Promise(r=>setTimeout(r,200));} const parity=[]; const {root}=await call('DOM.getDocument');const {nodeId}=await call('DOM.querySelector',{nodeId:root.nodeId,selector:'#inference-image'});await call('DOM.setFileInputFiles',{nodeId,files:[process.argv[3]]}); @@ -20,5 +20,15 @@ try{ const result=await evaluate(`({status:document.getElementById('inference-status').textContent,rows:document.querySelectorAll('#inference-results li').length,embedding:inferenceQuery?.embedding.length,insertion:inferenceQuery?.insertion,plane:document.getElementById('projection-plane').value})`); console.log(JSON.stringify({result,errors}));if(process.argv[4])writeFileSync(process.argv[4],JSON.stringify({result,errors},null,2)); if(!result.status.startsWith('Complete.')||result.rows!==15||!result.embedding||errors.length)throw Error('Browser acceptance failed'); + if(process.argv[5]){ + mkdirSync(process.argv[5],{recursive:true}); + await evaluate('document.getElementById("class-id-namespace").value="gbif";document.getElementById("class-id-namespace").onchange()'); + await new Promise(resolve=>setTimeout(resolve,12000)); + for(const [name,width,height] of [['desktop',1440,1000],['mobile',390,844]]){ + await call('Emulation.setDeviceMetricsOverride',{width,height,deviceScaleFactor:1,mobile:false}); + await evaluate('window.scrollTo(0,0)');await new Promise(resolve=>setTimeout(resolve,400)); + const shot=await call('Page.captureScreenshot',{format:'png'});writeFileSync(process.argv[5]+'/'+name+'.png',Buffer.from(shot.data,'base64')); + } + } await send('Browser.close'); }catch(error){console.error(error);chrome.kill();process.exitCode=1;}finally{clearTimeout(timeout);} diff --git a/dev/prototype_space/check_portable.mjs b/dev/prototype_space/check_portable.mjs new file mode 100644 index 00000000..8504803f --- /dev/null +++ b/dev/prototype_space/check_portable.mjs @@ -0,0 +1,78 @@ +// Optional browser smoke check. Set CHROMIUM_BIN; pass a report URL/path and output directory. +import {spawn} from 'node:child_process'; +import {writeFileSync,mkdtempSync,rmSync,mkdirSync,existsSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {pathToFileURL} from 'node:url'; +import {resolve,join} from 'node:path'; +const executable=process.env.CHROMIUM_BIN; +if(!executable||!existsSync(executable)||!process.argv[2])throw Error('Set CHROMIUM_BIN and pass report URL/path [output directory]'); +const output=resolve(process.argv[3]||'tmp/prototype-browser-check');mkdirSync(output,{recursive:true}); +const profile=mkdtempSync(tmpdir()+'/prototype-browser-'); +const chrome=spawn(executable,['--headless','--no-sandbox','--disable-gpu','--disable-dev-shm-usage','--no-first-run','--no-default-browser-check','--disable-background-networking','--disable-component-update','--disable-extensions','--remote-debugging-pipe','--user-data-dir='+profile],{stdio:['ignore','ignore','ignore','pipe','pipe']}); +let sequence=0,buffer=Buffer.alloc(0);const pending=new Map();const errors=[]; +chrome.stdio[4].on('data',chunk=>{buffer=Buffer.concat([buffer,chunk]);let end;while((end=buffer.indexOf(0))!==-1){const msg=JSON.parse(buffer.subarray(0,end));buffer=buffer.subarray(end+1);if(msg.id){const cb=pending.get(msg.id);if(cb){pending.delete(msg.id);msg.error?cb.reject(Error(JSON.stringify(msg.error))):cb.resolve(msg.result);}}else if(msg.method==='Runtime.exceptionThrown')errors.push(msg.params.exceptionDetails);}}); +function send(method,params={},sessionId){const id=++sequence;return new Promise((resolve,reject)=>{pending.set(id,{resolve,reject});chrome.stdio[3].write(JSON.stringify({id,method,params,...(sessionId?{sessionId}:{})})+'\0');});} +const deadline=setTimeout(()=>{console.error('Browser smoke timed out');chrome.kill('SIGTERM');process.exitCode=1;},60000); +try{ + const {targetId}=await send('Target.createTarget',{url:'about:blank'});const {sessionId}=await send('Target.attachToTarget',{targetId,flatten:true}); + const call=(m,p)=>send(m,p,sessionId);await call('Page.enable');await call('Runtime.enable');await call('Emulation.setDeviceMetricsOverride',{width:1440,height:1100,deviceScaleFactor:1,mobile:false}); + await call('Page.navigate',{url:process.argv[2].startsWith('http')?process.argv[2]:pathToFileURL(process.argv[2]).href}); + let ready=false;for(let i=0;i<100;i++){const r=await call('Runtime.evaluate',{expression:'typeof captureViewerState === "function" ? document.querySelectorAll("#neighbours tr").length : 0',returnByValue:true});if(r.result.value>0){ready=true;break;}await new Promise(r=>setTimeout(r,100));} + if(!ready)throw Error('Inspector did not render: '+JSON.stringify(errors)); + const result=await call('Runtime.evaluate',{expression:`(async()=>{ + const checks=[];const check=(v,s)=>{if(!v)throw Error(s);checks.push(s)}; + check(typeof BrowserGBIF==='function','browser client available'); + check(!document.getElementById('gbif-names'),'one namespace setting'); + check(document.body.dataset.view==='projection','explore default'); + document.getElementById('activity-predict').click(); + check(!document.getElementById('inference-panel').hidden,'predict navigation'); + const state=captureViewerState();check(state.version===2,'versioned state'); + document.getElementById('activity-explore').click(); + check(document.body.dataset.view==='projection','explore navigation'); + let terminated=false;inferenceWorker={terminate(){terminated=true;}};resetInferenceModel();check(terminated&&inferenceManifest===null&&$('inference-image').disabled,'model reset cancels stale inference'); + const id=data.names[0];let requests=0; + gbifClient.fetcher=async url=>{requests++;const taxon=url.includes('occurrence/search')?new URL(url).searchParams.get('taxonKey'):url.split('/').pop();return {ok:true,json:async()=>url.includes('occurrence/search')?{results:[{key:1,speciesKey:Number(taxon),media:[{type:'StillImage',identifier:location.origin+'/fixture.jpg',creator:'Fixture author',license:'CC BY'}]}]}:{key:Number(taxon),canonicalName:'Example '+taxon}};}; + $('class-id-namespace').value='gbif';$('class-id-namespace').onchange(); + check((await gbifClient.name(id)).display_name==='Example '+id,'browser names'); + const album=await referenceAlbum(id,new AbortController().signal); + check(album.photos[0].creator==='Fixture author','browser photo attribution'); + const legacy=captureViewerState();legacy.version=1;delete legacy.controls['class-id-namespace'];legacy.controls['gbif-names']=true; + applyViewerState(legacy);check($('class-id-namespace').value==='gbif','legacy namespace migration'); + await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))); + let rejected=false;try{applyViewerState({...captureViewerState(),version:1,controls:null});}catch{rejected=true;}check(rejected,'malformed legacy state rejected'); + const before={...projectionView};const classes=[data.names,...[1,2].map(level=>[...new Set(data.groups.map(group=>group[level]))])]; + lastPrediction={result:{outputs:classes.map(names=>names.map((_,i)=>i===0?10:0))},m:{classes}};renderInferenceResults(); + await new Promise(resolve=>setTimeout(resolve,300)); + check($('inference-results').querySelectorAll('figure').length===5,'species prediction thumbnails'); + check($('inference-results').textContent.includes('Fixture author'),'prediction credits'); + check(JSON.stringify(before)===JSON.stringify(projectionView),'prediction labels preserve map transform'); + $('class-id-namespace').value='generic';$('class-id-namespace').onchange(); + const count=requests;await new Promise(resolve=>setTimeout(resolve,300)); + check(requests===count,'generic mode prevents new GBIF requests'); + check(!$('inference-results').querySelector('figure'),'generic prediction fallback'); + $('class-id-namespace').value='gbif';$('class-id-namespace').onchange();setWorkspaceView('predict'); + await new Promise(resolve=>setTimeout(resolve,300)); + return checks; + })()`,returnByValue:true,awaitPromise:true}); + if(result.exceptionDetails)throw Error(JSON.stringify({exception:result.exceptionDetails,errors})); + for(const [name,width,height] of [['desktop',1440,1000],['mobile',390,844]]){ + await call('Emulation.setDeviceMetricsOverride',{width,height,deviceScaleFactor:1,mobile:false}); + await new Promise(resolve=>setTimeout(resolve,500)); + const shot=await call('Page.captureScreenshot',{format:'png'});writeFileSync(join(output,name+'.png'),Buffer.from(shot.data,'base64')); + } + if(process.argv.includes('--live')){ + const live=await call('Runtime.evaluate',{expression:`(async()=>{ + const client=new BrowserGBIF();const name=await client.name('1775152');const album=await client.album('1775152'); + if(!album.photos.length)throw Error('No live reference images'); + const image=document.createElement('img');document.body.append(image); + let loaded=false;for(const photo of album.photos){if(await loadReferenceImage(image,photo.image_path,new AbortController().signal)){loaded=true;break;}} + image.remove();if(!loaded)throw Error('Live reference images unavailable'); + return {name:name.display_name,photos:album.photos.length,imageLoaded:loaded}; + })()`,awaitPromise:true,returnByValue:true}); + if(live.exceptionDetails)throw Error(JSON.stringify(live.exceptionDetails)); + console.log(JSON.stringify({liveGBIF:live.result.value})); + } + if(errors.length)throw Error(JSON.stringify(errors)); + const report={checks:result.result.value,errors:errors.length};writeFileSync(join(output,'portable-check.json'),JSON.stringify(report,null,2));console.log(JSON.stringify(report)); + await send('Browser.close'); +}catch(e){console.error(e);process.exitCode=1;chrome.kill('SIGTERM');}finally{clearTimeout(deadline);setTimeout(()=>rmSync(profile,{recursive:true,force:true}),1000);} diff --git a/dev/prototype_space/prepare_gbif.py b/dev/prototype_space/prepare_gbif.py new file mode 100644 index 00000000..e1d9a979 --- /dev/null +++ b/dev/prototype_space/prepare_gbif.py @@ -0,0 +1,68 @@ +"""Prepare a resumable, optional species-name snapshot for a static report.""" + +import argparse +import json +import time +from datetime import UTC, datetime +from pathlib import Path +from urllib.request import Request, urlopen + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--report", type=Path, required=True, help="Exported report-data.json") + parser.add_argument("--output", type=Path, required=True, help="Destination gbif-snapshot.json; resumes existing snapshots") + parser.add_argument("--limit", type=int, default=0, help="Maximum new lookups; zero means all missing IDs") + args = parser.parse_args() + payload = json.loads(args.report.read_text()) + ids = set() + for case in payload.values(): + if case["metadata"].get("synthetic"): + continue + ids.update(str(name) for name in case["names"]) + for group in case.get("groups", []): + ids.update(str(name) for name in group) + ids = sorted(name for name in ids if name.isdigit()) + snapshot = ( + json.loads(args.output.read_text()) + if args.output.exists() + else { + "schema": "mini-trainer-gbif-v1", + "source": "https://api.gbif.org/v1/species/", + "taxa": {}, + "retrieved_at": {}, + } + ) + if snapshot.get("schema") != "mini-trainer-gbif-v1": + raise ValueError("Unsupported snapshot schema") + args.output.parent.mkdir(parents=True, exist_ok=True) + missing = [name for name in ids if name not in snapshot["taxa"]] + if args.limit: + missing = missing[: args.limit] + print(f"{len(ids)} IDs; {len(missing)} new lookups; existing entries retained", flush=True) + for index, name in enumerate(missing): + try: + request = Request("https://api.gbif.org/v1/species/" + name, headers={"User-Agent": "mini-trainer-prototype-snapshot/1"}) + with urlopen(request, timeout=15) as response: + taxon = json.load(response) + if str(taxon.get("key")) != name: + raise ValueError("Returned taxon ID differs") + snapshot["taxa"][name] = { + key: taxon[key] + for key in ("key", "canonicalName", "scientificName", "acceptedKey", "rank", "taxonomicStatus") + if key in taxon + } + snapshot["retrieved_at"][name] = datetime.now(UTC).isoformat() + except Exception as error: + print(f"{name}: {error}; left missing for browser lookup or later retry", flush=True) + temporary = args.output.with_suffix(".partial") + temporary.write_text(json.dumps(snapshot, ensure_ascii=False) + "\n") + temporary.replace(args.output) + if (index + 1) % 50 == 0: + print(f"{index + 1}/{len(missing)} attempted; {len(snapshot['taxa'])} cached", flush=True) + time.sleep(0.1) + print(f"Snapshot: {args.output}; {len(snapshot['taxa'])} taxa", flush=True) + + +if __name__ == "__main__": + main() diff --git a/dev/wheel_smoke.py b/dev/wheel_smoke.py index 61a43c3b..7e098b52 100644 --- a/dev/wheel_smoke.py +++ b/dev/wheel_smoke.py @@ -57,6 +57,7 @@ def main(): "report.html", "launcher.html", "photos.js", + "gbif.js", "projection.js", "thumbnails.js", "state.js", diff --git a/mini_trainer/visualization/prototype_space/explore.py b/mini_trainer/visualization/prototype_space/explore.py index f7df1c89..a1d67fd5 100644 --- a/mini_trainer/visualization/prototype_space/explore.py +++ b/mini_trainer/visualization/prototype_space/explore.py @@ -273,8 +273,14 @@ def create_report(weights: Path, output: Path, *, include_tsne=True, synthetic=F def render_report(output, payload): """Render current viewer assets over preserved numerical data.""" template = Path(__file__).with_name("report.html").read_text() + snapshot_path = output / "gbif-snapshot.json" + snapshot = snapshot_path.read_text() if snapshot_path.exists() else "null" + # Embed optional metadata as data, never executable HTML. + snapshot = json.dumps(json.loads(snapshot)).replace("<", "\\u003c") (output / "explorer.html").write_text( template.replace("__REPORT_DATA__", payload) + .replace("__GBIF_DATA__", snapshot) + .replace("__GBIF_SCRIPT__", Path(__file__).with_name("gbif.js").read_text()) .replace("__PHOTO_SCRIPT__", Path(__file__).with_name("photos.js").read_text()) .replace("__INFERENCE_SCRIPT__", Path(__file__).with_name("inference.js").read_text()) .replace("__PROJECTION_SCRIPT__", Path(__file__).with_name("projection.js").read_text()) diff --git a/mini_trainer/visualization/prototype_space/inference.js b/mini_trainer/visualization/prototype_space/inference.js index 980f301d..39edc6e4 100644 --- a/mini_trainer/visualization/prototype_space/inference.js +++ b/mini_trainer/visualization/prototype_space/inference.js @@ -1,8 +1,10 @@ /* Predictions use the exported forward; PCA queries use the fitted prototype transform. */ -let inferenceWorker=null,inferenceManifest=null,inferenceQuery=null,inferenceSequence=0; +let inferenceWorker=null,inferenceManifest=null,inferenceQuery=null,inferenceSequence=0,predictionPhotos=null,lastPrediction=null,inferencePreviewURL=null; const inferenceStatus=message=>$('inference-status').textContent=message; +function resetInferenceModel(){++inferenceSequence;inferenceWorker?.terminate();inferenceWorker=null;inferenceManifest=null;inferenceQuery=null;lastPrediction=null;predictionPhotos?.abort();$('inference-results').replaceChildren();$('inference-image').disabled=true;$('inference-preview').hidden=true;if(inferencePreviewURL){URL.revokeObjectURL(inferencePreviewURL);inferencePreviewURL=null;}inferenceStatus('Load the matching browser bundle to begin.');} + $('inference-load').onclick=async()=>{ - inferenceWorker?.terminate();inferenceWorker=null;inferenceQuery=null;inferenceManifest=null; + inferenceWorker?.terminate();inferenceWorker=null;inferenceQuery=null;inferenceManifest=null;lastPrediction=null;predictionPhotos?.abort();$('inference-results').replaceChildren(); $('inference-image').disabled=true;++inferenceSequence; try{ const url=new URL($('inference-url').value,location.href); @@ -26,6 +28,7 @@ $('inference-load').onclick=async()=>{ $('inference-cancel').onclick=()=>{++inferenceSequence;inferenceWorker?.terminate();inferenceWorker=null;inferenceManifest=null;$('inference-image').disabled=true;inferenceStatus('Stopped. Load the model to continue.');}; $('inference-image').onchange=async event=>{ const file=event.target.files[0];if(!file||!inferenceManifest)return; + if(inferencePreviewURL)URL.revokeObjectURL(inferencePreviewURL);inferencePreviewURL=URL.createObjectURL(file);$('inference-preview').src=inferencePreviewURL;$('inference-preview').hidden=false; const id=++inferenceSequence;$('inference-image').disabled=true;inferenceStatus('Preparing image…'); try{ rejectRotatedExif(await file.arrayBuffer()); @@ -62,17 +65,27 @@ function browserPreprocess(image,m){ } return output; } -function showInference(result,m){ +function renderInferenceResults(){ + if(!lastPrediction)return;const {result,m}=lastPrediction; + predictionPhotos?.abort();predictionPhotos=new AbortController();const signal=predictionPhotos.signal; const container=$('inference-results');container.replaceChildren(); result.outputs.forEach((scores,level)=>{ const max=Math.max(...scores),exp=scores.map(x=>Math.exp(x-max)),sum=exp.reduce((a,b)=>a+b,0); const heading=document.createElement('h3');heading.textContent=`Level ${level}`;container.append(heading); - const list=document.createElement('ol'); + const list=document.createElement('ol');list.setAttribute('aria-label',`Level ${level} predictions`);list.setAttribute('data-level',level); scores.map((score,i)=>({score,i})).sort((a,b)=>b.score-a.score||a.i-b.i).slice(0,5).forEach(({i})=>{ - const row=document.createElement('li'),button=document.createElement('button');button.textContent=`${m.classes[level][i]} · ${(100*exp[i]/sum).toFixed(2)}%`; - button.onclick=()=>{const index=level===0?i:data.groups.findIndex(group=>group[level]===m.classes[level][i]);if(index>=0)selectClass(index);};row.append(button);list.append(row); + const row=document.createElement('li'),button=document.createElement('button'),id=m.classes[level][i];const label=level===0?classLabel(i):id;button.textContent=`${label}${label===id?'':' · '+id} · ${(100*exp[i]/sum).toFixed(2)}%`; + button.onclick=()=>{if(level===0)selectClass(i);};if(level!==0){button.disabled=true;button.title='Higher-rank prediction; no individual species selected.';}row.append(button);list.append(row); + if(gbifEnabled()){ + const id=m.classes[level][i]; + gbifClient.name(id).then(value=>{if(!signal.aborted&&gbifEnabled()&&!(level===0&&(importedNames.get(data)?.has(i)||data.display_names?.[i])))button.textContent=`${value.display_name} · ${id} · ${(100*exp[i]/sum).toFixed(2)}%`;}).catch(()=>{}); + if(level===0)showPredictionPhoto(row,id,signal); + } });container.append(list); }); +} +function showInference(result,m){ + lastPrediction={result,m};renderInferenceResults(); inferenceQuery={embedding:result.embedding,checkpoint:m.checkpoint_sha256,insertion:result.insertion}; const plane=result.insertion?'Angular t-SNE':Object.keys(data.projections).find(name=>data.projections[name].transform); if(plane){$('projection-plane').value=plane;drawProjection();} @@ -109,3 +122,16 @@ function rejectRotatedExif(buffer){ }p+=size; } } + +async function showPredictionPhoto(row,id,signal){ + const figure=document.createElement('figure'),image=document.createElement('img'),caption=document.createElement('figcaption'); + image.width=88;image.height=88;image.style.objectFit='contain';image.loading='lazy';image.alt='Reference example for class '+id; + caption.textContent='Loading reference example…';figure.append(image,caption);row.append(figure); + try{ + const album=await referenceAlbum(id,signal);if(signal.aborted||!gbifEnabled())return; + const photo=album.photos[0];if(!photo){image.remove();caption.textContent='No reference image available.';return;} + caption.replaceChildren(photoText('span',`Reference example · ${photo.creator} · ${photo.license} · `),photoLink('Source',photo.source),photoText('span',' · '),photoLink('GBIF record','https://www.gbif.org/occurrence/'+photo.occurrence_id)); + const loaded=await loadReferenceImage(image,photo.image_path,signal); + if(!loaded&&!signal.aborted){image.remove();caption.prepend(photoText('span','Image unavailable. '));} + }catch(error){if(!signal.aborted){image.remove();caption.textContent='Reference image unavailable.';}} +} diff --git a/mini_trainer/visualization/prototype_space/photos.js b/mini_trainer/visualization/prototype_space/photos.js index af443841..1668e0ef 100644 --- a/mini_trainer/visualization/prototype_space/photos.js +++ b/mini_trainer/visualization/prototype_space/photos.js @@ -1,4 +1,4 @@ -/* Optional GBIF reference-photo layer. Numerical report works without this server. */ +/* Optional reference photos using the shared browser GBIF client. */ let photoController = null; let photoGeneration = 0; const photoAlbums = new Map(); @@ -11,20 +11,20 @@ const albumRequests=new Map(),albumQueue=[]; let activeAlbumRequests=0; function referenceAlbum(id,signal){ if(photoAlbums.has(id))return Promise.resolve(photoAlbums.get(id)); - const existing=albumRequests.get(id);if(existing){existing.signals.push(signal);return existing.promise;} - const job={id,signals:[signal]};job.promise=new Promise((resolve,reject)=>{job.resolve=resolve;job.reject=reject;}); + const existing=albumRequests.get(id);if(existing&&existing.generation===gbifClient.controller.signal){existing.signals.push(signal);return existing.promise;} + const job={id,signals:[signal],generation:gbifClient.controller.signal};job.promise=new Promise((resolve,reject)=>{job.resolve=resolve;job.reject=reject;}); albumRequests.set(id,job);albumQueue.push(job);pumpAlbumRequests();return job.promise; } function pumpAlbumRequests(){ while(activeAlbumRequests<4&&albumQueue.length){ const job=albumQueue.shift(); - if(job.signals.every(signal=>signal.aborted)){albumRequests.delete(job.id);job.reject(new DOMException('Navigation cancelled','AbortError'));continue;} + if(job.generation.aborted||job.signals.every(signal=>signal.aborted)){if(albumRequests.get(job.id)===job)albumRequests.delete(job.id);job.reject(new DOMException('Navigation cancelled','AbortError'));continue;} activeAlbumRequests++; (async()=>{try{ - const response=await fetch('/api/gbif/'+job.id,{signal:AbortSignal.timeout(12000)}),album=await response.json(); - if(!response.ok)throw Error(album.error||'Photo lookup failed'); + if(!gbifEnabled())throw new DOMException('GBIF disabled','AbortError'); + const album=await gbifClient.album(job.id); photoAlbums.set(job.id,album);job.resolve(album); - }catch(error){job.reject(error);}finally{albumRequests.delete(job.id);activeAlbumRequests--;pumpAlbumRequests();}})(); + }catch(error){job.reject(error);}finally{if(albumRequests.get(job.id)===job)albumRequests.delete(job.id);activeAlbumRequests--;pumpAlbumRequests();}})(); } } @@ -100,24 +100,16 @@ async function renderPhotos() { const signal = photoController.signal; const grid = $('photo-grid'); grid.replaceChildren(); - if (!$('photo-enabled').checked) { - $('photo-status').textContent = 'Enable to browse the selected class and its nearest directions using GBIF class examples.'; - return; - } const ids = [selected, ...data.neighbours[selected]]; if (data.metadata.synthetic || !ids.some(i => photoNumber(data.names[i]))) { $('photo-status').textContent = 'This case has no GBIF-ID candidates. No online lookup was made.'; return; } - $('photo-status').textContent = 'Loading class examples sequentially; class IDs and numerical neighbourhoods stay unchanged.'; - try { - const health = await fetch('/api/health', {signal}); - if (!health.ok || !(await health.json()).photo_api) throw Error('Photo service unavailable'); - } catch (error) { - if (signal.aborted) return; - $('photo-status').textContent = 'Photo view needs the local photo server: .venv/bin/python -m dev.prototype_space.serve --directory tmp/prototype-report. Open its localhost URL.'; + if (!gbifEnabled() || !$('photo-enabled').checked) { + $('photo-status').textContent = 'Select GBIF taxon IDs in Settings and enable reference photos to browse examples.'; return; } + $('photo-status').textContent = 'Loading class examples sequentially; class IDs and numerical neighbourhoods stay unchanged.'; const cards = ids.map((classIndex, rank) => { const card = photoText('article', '', 'photo-card' + (rank === 0 ? ' photo-selected' : '')); const open = photoText('button', '', 'photo-open'); diff --git a/mini_trainer/visualization/prototype_space/report.html b/mini_trainer/visualization/prototype_space/report.html index 4b876758..1a405e2d 100644 --- a/mini_trainer/visualization/prototype_space/report.html +++ b/mini_trainer/visualization/prototype_space/report.html @@ -67,25 +67,48 @@ body[data-view=projection] .projection-controls-content>p.muted{display:none} body[data-view=projection] .label-settings>div{position:static;width:auto;max-width:100%;box-shadow:none} body[data-view=projection] #score-note{order:0;padding:12px;margin:0} + +#activity-nav{display:flex;align-items:center;gap:10px;padding:10px 20px;background:#fff;border-bottom:1px solid var(--line);position:relative;z-index:60} +body[data-view=projection] #workspace-controls{top:var(--activity-height,56px)} +body[data-view=projection] #projection-controls{top:8px} +body[data-view=projection] #projection-panel.primary-view{height:calc(100dvh - var(--activity-height,56px))} +#activity-nav button{font-weight:600}#activity-nav button[aria-pressed=true]{background:#e2f2f1;border-color:var(--accent)} +body[data-view=predict] #workspace-controls{display:none} +.inference-actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:14px 0} +.inference-actions label{display:flex;align-items:center;gap:8px;min-width:0}.inference-actions input{max-width:100%;width:260px} +#inference-preview{object-fit:contain;border-radius:6px;margin:10px 0} +#inference-model-settings{font-size:13px;margin:12px 0}#inference-model-settings input{max-width:100%} +#shared-settings{margin-left:auto} +#shared-settings-content{position:absolute;right:12px;top:100%;width:350px;max-width:calc(100vw - 24px);max-height:75vh;overflow:auto;background:white;padding:16px;border:1px solid var(--line);box-shadow:0 5px 20px #0002} +#shared-settings-content .label-settings>div{position:static;box-shadow:none;width:auto;max-width:100%;border:0;padding:10px 0} +#shared-settings-content .label-settings{margin-bottom:12px} +#inference-results{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px} +#inference-results>h3{display:none}#inference-results ol:before{content:"Level " attr(data-level);display:block;font-weight:650;margin-bottom:8px}#inference-results ol{padding:0;list-style:none;min-width:0} +#inference-results li{padding:12px 0;border-bottom:1px solid var(--line)} +#inference-results figure{margin:8px 0;display:flex;gap:10px;align-items:center} +#inference-results figcaption{font-size:11px;overflow-wrap:anywhere}#inference-results img{flex-shrink:0;border-radius:6px} +body[data-view=predict] #inference-panel{width:100%}body[data-view=predict] #projection-panel{width:100%;height:65vh} +@media(max-width:650px){#inference-results{grid-template-columns:1fr}#activity-nav{padding:8px}#shared-settings-content{right:8px}} +
mini_trainer · empirical exploration

Prototype observatory

Move between the global tree, pairwise structure and a class’s local neighbourhood. The existing mini_trainer distance is the reference; the pre-CDF z-score exposes detail that can be hidden by probability rounding.

Explorer · view & class
-Save HTML report - +Save HTML report + -
Class names
+
Names and class identity

Try your own image

Run this model locally in your browser. Images are not uploaded. Model download and inference may take time on this device.

- - - +
Model source
+
+

Load the matching browser bundle to begin.

@@ -100,7 +123,7 @@
Map details & credits

Hover a point for its class ID.

Red: selected class · teal: its neighbours in the original space · orange rings: nearby in this plane only · grey: other classes. Links follow the original-space ranking. Projected gaps and apparent overlaps can differ from the full embedding geometry.

-

Browse by class image

Photo labels for the selected class and its nearest prototype directions. The prototype marks the learned target direction for the class; a GBIF class example provides its visual label. Credits and exact class IDs stay attached.

+

Browse by class image

Photo labels for the selected class and its nearest prototype directions. The prototype marks the learned target direction for the class; a GBIF class example provides its visual label. Credits and exact class IDs stay attached.

Explore the existing dendrogram

Same Ward linkage on class_distance, shown as a rectangular tree with at most 32 visible tips. Click a collapsed tip to expand its subtree; click a class to inspect it. Heights retain the existing linkage units.

@@ -123,6 +146,8 @@ + + @@ -279,11 +314,12 @@ diff --git a/mini_trainer/visualization/prototype_space/state.js b/mini_trainer/visualization/prototype_space/state.js index c0ef67ae..3a8bbac8 100644 --- a/mini_trainer/visualization/prototype_space/state.js +++ b/mini_trainer/visualization/prototype_space/state.js @@ -1,5 +1,5 @@ /* Versioned presentation state. Numerical data and transient image motion are excluded. */ -const viewerStateControls=['workspace-view','projection-plane','metric','global-metric','range-preset','range-low','range-high','score-display','class-names','gbif-names','photo-enabled','map-photos','map-photo-size','map-photo-density','map-photo-overlap','map-photo-labels','map-photo-push','map-photo-aspect','map-photo-anchors','projection-edges']; +const viewerStateControls=['workspace-view','projection-plane','metric','global-metric','range-preset','range-low','range-high','score-display','class-names','class-id-namespace','photo-enabled','map-photos','map-photo-size','map-photo-density','map-photo-overlap','map-photo-labels','map-photo-push','map-photo-aspect','map-photo-anchors','projection-edges']; let restoringViewerState=false,viewerStateTimer,viewerServerToken=null,viewerStateChanged=false,viewerStateWrites=Promise.resolve(); const viewerDocumentKey='prototype-case:'+(Object.values(cases).find(c=>!c.metadata.synthetic)?.metadata.checkpoint_sha256||Object.keys(cases).join(':')); function viewerIdentity(){ @@ -7,7 +7,7 @@ function viewerIdentity(){ return [data.metadata.checkpoint_sha256||'synthetic',data.metadata.seed||0,$('case').value,data.names.length,hash>>>0].join(':'); } function captureViewerState(){ - return {version:1,identity:viewerIdentity(),selected:data.names[selected],tree:treeFocus,view:{...projectionView},controls:Object.fromEntries(viewerStateControls.map(id=>[id,$(id).type==='checkbox'?$(id).checked:$(id).value]))}; + return {version:2,identity:viewerIdentity(),selected:data.names[selected],tree:treeFocus,view:{...projectionView},controls:Object.fromEntries(viewerStateControls.map(id=>[id,$(id).type==='checkbox'?$(id).checked:$(id).value]))}; } function stateMessage(message){$('viewer-state-status').textContent=message;} function saveViewerState(){ @@ -17,10 +17,11 @@ function saveViewerState(){ if(viewerServerToken){const body=JSON.stringify({key:viewerDocumentKey,state:{case:$('case').value,view:captureViewerState()}});viewerStateWrites=viewerStateWrites.then(()=>fetch('/api/view-state',{method:'POST',headers:{'Content-Type':'application/json','X-Explorer-Token':viewerServerToken},body,keepalive:true})).catch(()=>stateMessage('Local server state unavailable; browser save/export remains available.'));} } function applyViewerState(state){ - if(state?.version!==1||state.identity!==viewerIdentity())throw Error('State belongs to a different model/case or unsupported version.'); + if(![1,2].includes(state?.version)||state.identity!==viewerIdentity())throw Error('State belongs to a different model/case or unsupported version.'); + if(!state.controls||typeof state.controls!=='object'||Array.isArray(state.controls))throw Error('Missing saved controls'); + if(state.version===1){const controls={...state.controls};controls['class-id-namespace']=['gbif-names','photo-enabled','map-photos'].some(id=>controls[id]===true)?'gbif':'generic';delete controls['gbif-names'];state={...state,version:2,controls};} const index=data.names.indexOf(state.selected),v=state.view; if(index<0||!v||![v.x,v.y,v.scale].every(Number.isFinite)||v.scale<=0||!Number.isInteger(state.tree)||state.tree<0||state.tree>=data.names.length*2-1)throw Error('Invalid saved selection or geometry.'); - if(!state.controls||typeof state.controls!=='object')throw Error('Missing saved controls.'); for(const [id,value] of Object.entries(state.controls)){ if(!viewerStateControls.includes(id))continue;const node=$(id); if(node.type==='checkbox'){if(typeof value!=='boolean')throw Error('Invalid checkbox state.');} @@ -30,6 +31,7 @@ function applyViewerState(state){ restoringViewerState=true; try{ for(const [id,value] of Object.entries(state.controls)){if(!viewerStateControls.includes(id))continue;const node=$(id);if(node.type==='checkbox')node.checked=value;else node.value=value;} + $('class-id-namespace').onchange(); setWorkspaceView($('workspace-view').value);treeFocus=state.tree;selectClass(index); $('score-note').hidden=$('score-display').value!=='tail';drawHistogram();drawGlobal();setThumbnailAnchors(); // Wait for the new focused layout's ResizeObserver before restoring its transform. @@ -42,7 +44,7 @@ function restoreViewerState(){ } function queueViewerState(){if(restoringViewerState)return;viewerStateChanged=true;clearTimeout(viewerStateTimer);viewerStateTimer=setTimeout(saveViewerState,400);} const stateMenu=document.createElement('details');stateMenu.className='label-settings';stateMenu.innerHTML='Saved view
'; -document.querySelector('.controls').append(stateMenu); +$('shared-settings-content').append(stateMenu); $('viewer-state-export').onclick=()=>{const url=URL.createObjectURL(new Blob([JSON.stringify(captureViewerState(),null,2)],{type:'application/json'}));const a=document.createElement('a');a.href=url;a.download='prototype-view.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}; $('viewer-state-import').onchange=async()=>{try{const file=$('viewer-state-import').files[0];if(file)applyViewerState(JSON.parse(await file.text()));}catch(error){stateMessage(error.message);}}; $('viewer-state-reset').onclick=async()=>{restoringViewerState=true;clearTimeout(viewerStateTimer);try{localStorage.removeItem('prototype-view:'+viewerIdentity());await viewerStateWrites;if(viewerServerToken)await fetch('/api/view-state',{method:'POST',headers:{'Content-Type':'application/json','X-Explorer-Token':viewerServerToken},body:JSON.stringify({key:viewerDocumentKey,state:null})});}catch{}location.reload();}; diff --git a/mini_trainer/visualization/prototype_space/thumbnails.js b/mini_trainer/visualization/prototype_space/thumbnails.js index 8f9492d2..d426c6c2 100644 --- a/mini_trainer/visualization/prototype_space/thumbnails.js +++ b/mini_trainer/visualization/prototype_space/thumbnails.js @@ -237,15 +237,15 @@ function scheduleMapThumbnails(){ if(!projectionCanvas.getBoundingClientRect().width)return; const size=Number($('map-photo-size').value),density=Number($('map-photo-density').value),overlap=Number($('map-photo-overlap').value); const key=[size,$('map-photo-labels').checked,$('map-photo-push').checked,$('map-photo-aspect').checked,$('projection-plane').value].join(':'); - if($('map-photos').checked&&mapThumbCase===data&&mapThumbKey===key){ + if(gbifEnabled()&&$('map-photos').checked&&mapThumbCase===data&&mapThumbKey===key){ const rect=projectionCanvas.getBoundingClientRect(); reanchorThumbnails(projectionHits.map(([x,y])=>[x*rect.width/1200,y*rect.height/projectionHeight]),rect.width,rect.height); mapThumbOffsets=new Map(mapThumbLayout.map(b=>[b.id,[b.x-b.anchorX,b.y-b.anchorY]])); }else{mapThumbOffsets.clear();mapThumbLayout=[];$('map-thumbnail-layer').replaceChildren();} mapThumbCase=data;mapThumbKey=key; $('map-size-value').value=size+' px';$('map-density-value').value=density+' / MP';$('map-overlap-value').value=overlap+'%'; - if(!$('map-photos').checked){$('map-photo-status').textContent='Enable map thumbnails to interpret numeric class IDs as GBIF taxa. Push apart connects displaced images to their fixed prototype points.';return;} if(data.metadata.synthetic){$('map-photo-status').textContent='Synthetic case: no GBIF photo requests.';return;} + if(!gbifEnabled()||!$('map-photos').checked){$('map-photo-status').textContent='Select GBIF class IDs in settings and enable map thumbnails. Push apart connects displaced images to their fixed prototype points.';return;} $('map-photo-status').textContent='Updating visible thumbnails…'; mapThumbTimer=setTimeout(renderMapThumbnails,180); } @@ -273,8 +273,6 @@ async function renderMapThumbnails(){ let loaded=0,unavailable=0; const status=()=>{$('map-photo-status').textContent=`${loaded} images loaded · ${motionFinished?mapThumbLayout.length:boxes.length} visible slots · budget ${budget} for this viewport. ${config.push?`Push apart: ${motionFinished?`${mapThumbRest?.reason||'resting'} · ${mapThumbRest?.pairs||0} overlapping pairs retained`:'adjusting rectangle contacts'}. Images move at most ${2*size} px.`:`Maximum pairwise rectangle overlap ${Math.round(config.overlap*100)}%.`} ${unavailable} unavailable. Hover for names and credits; click to inspect. Prototype coordinates and neighbour ranks stay fixed.`;}; if(!boxes.length){status();return;} - try{if(boxes.some(b=>!retained.has(b.id))){const health=await fetch('/api/health',{signal});if(!health.ok||!(await health.json()).photo_api)throw Error('Photo server unavailable');}} - catch(error){if(!signal.aborted)$('map-photo-status').textContent='Map photos require the local GBIF photo server. Numerical views remain available.';return;} const loadBox=async box=>{ if(signal.aborted||generation!==mapThumbGeneration||data!==caseData)return; const id=data.names[box.id]; diff --git a/tests/utils/test_prototype_exploration.py b/tests/utils/test_prototype_exploration.py index a5d38038..43a9fee6 100644 --- a/tests/utils/test_prototype_exploration.py +++ b/tests/utils/test_prototype_exploration.py @@ -90,3 +90,19 @@ def test_log_domain_scores_preserve_tails_against_independent_erfc_reference(): # Beyond the float64 linear-probability range, log-tail still has meaning. extreme_d, extreme_logtail = stable_scores(torch.tensor([56.0])) assert extreme_d.item() == 0 and torch.isfinite(extreme_logtail).all() + + +def test_optional_gbif_snapshot_is_embedded_as_data(tmp_path): + import json + + from mini_trainer.visualization.prototype_space.explore import render_report + + render_report(tmp_path, "{}") + assert '' in (tmp_path / "explorer.html").read_text() + snapshot = {"schema": "mini-trainer-gbif-v1", "taxa": {"1": {"key": 1, "canonicalName": ""}}} + (tmp_path / "gbif-snapshot.json").write_text(json.dumps(snapshot)) + render_report(tmp_path, "{}") + html = (tmp_path / "explorer.html").read_text() + assert "