Skip to content

v2.0.3 - Projection - #312

Open
guillaume-byte wants to merge 66 commits into
mainfrom
dev
Open

guillaume-byte wants to merge 66 commits into
mainfrom
dev

Conversation

@guillaume-byte

Copy link
Copy Markdown
Member

Summary

This PR adds learned 2D/3D projections during training so model representations can be explored live in the Studio.

By default, it uses a parametric UMAP trained alongside the model, allowing the projection to track representation changes as training progresses and to embed new samples without refitting. The projection can be configured per model, turned off when needed, and rendered alongside training progress in the Studio.

What’s included

  • Learned projection during training with a parametric UMAP
  • 2D and 3D output support
  • Configurable projection source layer
  • Per-model and global enable/disable controls
  • Support for alternate algorithms such as PCA, t-SNE, or custom fit_transform methods
  • Saved and named projections that can be switched in the Studio
  • Stable, stratified sampling for large datasets in the backend
  • Full-sample lasso selection on the backend for large point clouds

Why

This makes model representation exploration practical at scale:

  • the projection updates as the model changes,
  • large datasets remain responsive in the browser,
  • users can inspect clusters and select data points without loading everything into the client.

Implementation notes

  • Added learned parametric UMAP integration in the training pipeline
  • Added projection configuration hooks for model-level setup
  • Added Studio integration for selecting and switching projections
  • Added backend subsampling and full-sample selection for large projections
  • Added documentation for setup and custom projections

Acceptance criteria

  • Live projection appears in the Studio during training
  • 2D and 3D projections render correctly
  • Projection layer is visible in the UI
  • Built-in and custom projections appear in the picker
  • Large projections remain responsive without loading the full point cloud
  • Lasso selection works over all samples, not only visible ones
  • Configuration and custom projection docs are included

Related issue

Fixes #309

guillaume-byte and others added 30 commits August 26, 2026 12:20
* feat(agent): vendored OpenCode binary + auto-install/init and container-tunnel support
---------------
- opencode_binary.py: Node-free on-demand provisioning of the OpenCode
  standalone binary (npm-registry tarball via stdlib), managed per-user cache
- resolver prefers managed binary; background auto-install on import/start
- `weightslab agent init` CLI; agent-config gating with an info hint (no
  implicit sign-in)
- configurable OpenCode bind host + UI trusted-hosts allowlist so the agent
  works through a container's published port / SSH tunnel
- unit tests + CI agent-smoke job

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(agent): Getting Started via `weightslab agent init` + OpenCode env var reference

- agent_quickstart: lead with `weightslab agent init` (provisions the Node-free
  OpenCode binary, then signs in); document --provision-only and the "agent not
  initialized" info behavior so a new user knows exactly what to do.
- configuration: document the OpenCode provisioning/bind env vars —
  WEIGHTSLAB_OPENCODE_HOST, WEIGHTSLAB_UI_TRUSTED_HOSTS,
  WEIGHTSLAB_OPENCODE_AUTOINSTALL/AUTODOWNLOAD/VERSION/HOME — incl. the
  container-behind-a-tunnel setup.
* fix(v2.1): video-gen report media + server-authoritative subview flag

- reporting: add a "Generated Media" section (poster thumbnails per media
  field) so video/image-generation runs show their artifacts instead of an
  empty report; guard media columns in the Distributions path (no longer
  mislabelled "no numeric values"); surface a swallowed get_combined_df error
  as a warning so a broken dataframe isn't silently hidden.
- data_service/proto: DataSamplesResponse gains is_subview/view_count/
  total_count, stamped from the backend's _is_filtered state on every
  GetDataSamples, so a fresh client can render the subview warning ribbon with
  no cached UI state. Regenerated pb2 with grpcio-tools 1.68 (gencode 5.28.1).
- tests: report media/distribution-guard coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(v2.1): backend min/max curve decimation so spikes survive 10k->1k

get_signal_history_downsampled emitted the earliest-step point per bucket, so a
spike between bucket edges (unless separately flagged as marker/note/outlier)
was dropped server-side before the browser ever saw it. Now it emits each
bucket's min-value AND max-value rows (min/max decimation); the bucket count is
halved so the total stays ~max_points. 10k points -> ~1k, with spikes.

+ test: a non-flagged mid-bucket value spike survives, output stays ~max_points.

* Check OS and adapt cmd
test_logger_scale.py deliberately builds multi-million-row DuckDB
fixtures to stress-test large-scale queries -- real, by-design heavy
work that easily exceeds the per-test timeout on a shared runner. Its
own marker registration already said "deselect with -m 'not scale'"
but the CI job never actually did, so each run burned its 600s
per-test timeout on these instead of skipping them, reading as a
hang stuck at the same progress percentage across unrelated pushes.
Examples no longer reach into the package internals. Every
`from weightslab.components.global_monitoring import (guard_training_context,
guard_testing_context)` is gone; the call sites use `wl.guard_training_context` /
`wl.guard_testing_context`, which are the very same singletons (the top-level
names are lazy re-exports of that module, verified by identity).

The Ultralytics trainers now have the same treatment: `WLAwareTrainer`,
`WLAwareSegmentationTrainer`, `WLAwareDataset` and `WLAwareSegmentationDataset`
are re-exported from the package root, so a YOLO script reads
`trainer=wl.WLAwareTrainer` with no deep import. They go through the lazy export
map rather than a plain import because `ultralytics` is an optional extra --
importing it eagerly would break `import weightslab` for everyone without it.
`__getattr__` now turns that missing dependency into an actionable error
("needs the optional 'ultralytics' package: pip install 'weightslab[ultralytics]'")
instead of a bare ModuleNotFoundError, and still raises AttributeError for names
that genuinely do not exist.

Notebooks are stripped of outputs and execution counts (23 notebooks verified
clean). Edits are byte-surgical rather than an nbformat round-trip: these files
carry different JSON indents (1 for Jupyter, 2 for Colab-saved) and CRLF, so a
round-trip would have reformatted whole files and buried the real change. Every
cell's source was diffed against HEAD to confirm nothing else moved.

Docs and code samples follow the same path (`wl.guard_*`, `wl.WLAwareTrainer`),
including the README wandb-migration diff and both AGENTS.md files so the next
agent does not reintroduce the deep imports. Prose mentions of the bare names
are left as they are -- those names remain importable from the package, so the
text stays correct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	docs/examples/ultralytics/detection.rst
…#305)

The studio notebook runs an embedded ipykernel inside the trainer's own
process, and IPKernelApp.initialize() swaps sys.stdout/sys.stderr
process-wide for an OutStream that publishes every write to iopub. So the
training loop's tqdm bar -- a different thread, writing continuously --
surfaced in whatever cell was last executed ("Training: 193497 steps ...
train_loss=1.4612" in a cell that never asked for it). The legacy in-process
kernel leaked the same way: contextlib.redirect_stdout is process-global too.

Route the streams per write instead of per process:

* _ThreadRoutedStream wraps ipykernel's OutStreams -- the thread currently
  running a cell reaches the kernel stream, every other thread (at any time,
  including while the kernel is idle) gets the console stream the process had
  before the kernel existed. Ownership comes from the pre_execute/post_execute
  hooks, which run on the real execution thread, so nothing here assumes which
  thread ipykernel picked for the shell channel.
* capture_fd_output=False, or ipykernel's fd 1/2 pipe would re-capture exactly
  the writes that were just routed back to the terminal. The cost is that
  output written straight to the fds by C extensions no longer reaches the
  notebook -- for an in-process kernel sharing a terminal with the trainer,
  that is the better trade.
* _LiveStream (legacy kernel) got the same thread check, falling back to the
  pre-redirect stream so those writes still reach the console.

Two tests: one in the shared contract class, so both kernels are held to it
(a background thread's output must not appear in a cell, while the cell's own
print still does), and a legacy-only one proving the other thread's writes
reach the console rather than being dropped.

Known consequence: output from a thread a cell itself spawns now goes to the
terminal, not the cell.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…es (#303)

* docs(perf): register O(data) operations blocking 100GB+ interactivity

Storage and serving paths whose cost scales with dataset size rather than with
what changed. Storage findings re-verified against dev; two serving costs noted
as already fixed upstream so they are not re-claimed as wins.

No code changes - baseline and measurement protocol only.

* docs(perf): triage every _slowUpdateInternals call site

16 of 18 sites only need fresh values for dirty rows (O(change)); only
first build and schema change need a full reconstruction. Records why
ApplyDataQuery filter paths stay on the rebuild (_is_filtered semantics)
and why a no-client benchmark cannot show the difference.

* perf(interactivity): make view refresh and ledger writes O(change)

Every path that kept the served view in sync ran O(dataset): a full
materialized-view rebuild on each signal tick, and a read-modify-append of
the whole H5 table per upsert. At 3.96M rows that meant a 670s startup and
lock holds long enough that the UI looked hung while training.

Three changes, each turning a whole-dataset pass into a per-change one:

data_service: differential view refresh (_fastUpdateInternals). The
materialized view only ever recomputes index-derived state, so a value-only
delta can be written straight into the existing view through a
sample_id -> row-position map instead of rebuilding it. Falls back to the
full path on any structural change (unknown sample_id, missing pos map,
backlog over max_dirty), so correctness never depends on the fast path
being right about a schema change. 9 call sites routed here; the 10 that
genuinely change shape still force a rebuild. Off with WL_FAST_VIEW=0.

The sort path is restructured to do its work off-lock: ops and the pos-map
rebuild both run on a shallow copy, and only the pointer swap happens under
the lock. Skipping the pos-map rebuild after a sort would have been a data
corruption bug -- sorting reorders the view, so stale positions send
differential writes to the wrong rows.

h5_dataframe_store: in-place row updates via modify_coordinates, with a
cached sample_id -> coordinate map (stable row positions come free from the
no-row-loss invariant). PyTables cannot invalidate a column index during
modify_coordinates, so _try_inplace refuses indexed tables and the caller
falls back to the append path. Index construction is also split from
storage layout: data_columns=True keeps the on-disk layout queryable while
index=False keeps the flush path from rebuilding an index no hot-path read
uses (92.7s -> 6.9s per upsert at 4M rows).

dataframe_manager: replaces the per-row iterrows() scans that dominated
startup with column-wise vectorised passes, and adds the dirty-row/source-row
accessors the differential refresh needs.

Also fixes an unrelated thumbnail bug in trainer_tools.process_sample: it
unpacked exactly 3 values from _getitem_raw, whose contract is
(data, id, target, *metadata). Any dataset implementing get_items() with
metadata raised "too many values to unpack" and every cell in the grid came
back with no image. Now unpacked positionally.

Measured on 3.96M-row UltraEdit, A10G:

  startup                670s -> 250s
  H5 upsert (24 rows)  127.9s -> ~16ms
  snapshot flush         338s -> 0 samples
  max lock hold      129,440ms -> none over 1s
  throughput under UI     13% -> 45-51% of idle

The residual loss under load is CPU/GIL contention (8 vCPUs shared by 6
dataloader workers, training, and image encode), not lock waiting.

Known gaps, deliberately left for review:
  - ensure_index() has no caller yet, and conflicts with _try_inplace, which
    refuses indexed tables. It documents the deliberate-index path but is
    dead code as committed.
  - _POSMAP_CACHE is class-level and never evicts (~400-600MB at 4M rows).
  - Three ApplyDataQuery sites still force a full rebuild pending a decision
    on _is_filtered semantics.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(view): stop the served view silently diverging from the ledger

The ledger was always correct; the view readers see was not, and every failure
mode reported itself as success. On a 3.96M-sample run the UI showed ~2k samples
with loss data at 34k steps, and toggling image modalities showed the source
image twice.

View correctness:

* Address differential-sync rows by the SAMPLE_ID index level. The view is
  indexed (origin, sample_id), so get_level_values(0) returned origin and its
  intersection with the dirty sample_ids was always empty -- the sync wrote
  nothing and returned True, which suppressed the rebuild that would have
  repaired it. Positions now come from Index.get_indexer (cached hash engine),
  so it stays vectorised.
* Rebuild when the ledger gains columns the view lacks. Per-sample signal
  columns are created on their first write, so on a fresh ledger the view
  predates them and could never gain them: sorting and histogramming failed
  with "column not in view" and last_seen served -1 forever. Checked before the
  dirty-set drain, since a schema gain is independent of dirty rows.
* Keep the view-dirty backlog until a rebuild actually lands. It was discarded
  on overflow assuming the caller would rebuild, but the force path returns
  early on a contended lock -- those ids were then lost with nothing left to
  re-mark them. Cleared at the atomic view swap instead.
* Log view-build failures as errors. They were swallowed at debug level and
  returned the previous view, so a broken build was indistinguishable from
  "no new data".

Named image views:

* Probe extra_images() on the unwrapped dataset -- WL's tracking wrapper does
  not forward it, so every named view was silently dropped.
* Honour stats_to_retrieve for image views, but still advertise filtered-out
  views with an empty thumbnail so their toggles do not vanish from the panel.

Cost:

* Loss-shape autotagging runs on its own interval
  (WL_LOSS_SHAPE_INTERVAL_SECONDS, default 60s) instead of the 2s flush tick,
  where each pass cost ~990ms of GIL-held pandas work.
* Signal-DAG history reads a bounded in-memory tail (WL_HISTORY_TAIL, default
  16) instead of scanning per_sample -- 140ms per step at 20M rows, growing
  without bound. Neither an index nor a rewritten IN clause helped (1.1x/1.4x).
* Skip array normalisation for columns the H5 write excludes: with predictions
  off it rasterised via get_mask, which reads the source image, for data that
  is never persisted.
* Close inherited HDF5 fds in forked dataloader workers; they made HDF5 refuse
  the parent's read-write open and killed ledger persistence for 12 hours.

Measured on the UltraEdit harness (859M params, batch 24, A10G) against an
identical run with weightslab stubbed out: 1574ms -> ~1290ms/step versus a
1171ms baseline, i.e. +34% -> ~+10%.

optrace.py is included: the @traced/hit markers the other files import are what
located the sample_id level bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(histogram): bin over rows that carry a value, not every row in the view

The numeric path cut bin boundaries by row position across the WHOLE view and
dropped non-finite values only afterwards, so every bucket spanned
len(view)/max_bins rows regardless of the data. On a 3,963,189-row view with
512 bins that is 7,740 rows per bucket -- so a column where only ~33k samples
carry a value (any signal early in a run) collapsed into the first four
buckets, and the remaining 500 sliced empty space into 1-6 sample slivers.

Visible as four fat bars followed by a long tail of random-looking spikes, with
the same 7740/7741 counts appearing on unrelated columns because the number
came from the row count, not the data.

These bars are a search surface over the loss landscape: each should be a
click-target holding a comparable number of samples. Mask first, then cut
equal-population boundaries over the finite subset. Row ORDER is untouched, so
this is still "bin the current view by row order" -- it just stops counting
rows that have nothing to show. Also fixes the per-(origin, discarded) sub-bars,
which were grouped by the same positional bins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(ledger): make the NB_SEEN lookup O(batch), and regenerate the protos

Three things, all needed to make the branch runnable on top of dev at 4M rows.

1. Regenerated experiment_service_pb2{,_grpc}.py

dev's .proto declares AnnotationExportFormat / EXPORT_FORMAT_CVAT but the
committed gencode predates it, so a clean checkout of dev does not import at
all:

    AttributeError: module 'weightslab.proto.experiment_service_pb2'
    has no attribute 'EXPORT_FORMAT_CVAT'

Regenerated with grpcio-tools 1.68.1 (protoc 5.28.1), matching the runtime
version already pinned in the file, so the gencode major does not move.

2. Cache the level-0 index for sample-id coercion

_coerce_sample_id_for_index() called index.get_level_values(0) on every
invocation. That materialises a fresh Index over all rows, and a fresh Index
carries a fresh hash engine, so each `sid in level_0_values` paid a full engine
build -- twice per sample when the int probe missed. enqueue_batch does that
per sample, 24x a step: training sat at 0 iterations with the main thread
pinned at 100% CPU inside pandas __contains__ (py-spy: active+gil).

Cached on the index object's identity. pandas Index is immutable, so any
reindex or rebuild yields a new object and invalidates it; membership semantics
are unchanged, the engine is simply reused.

3. Positional NB_SEEN lookup

get_sample_column_values() then still materialised both index levels, ran isin
over every row and copied a boolean-masked frame -- a full pass plus a copy
over 3.96M rows to read 24 integers, ~1.2s/step (signals 6ms -> 1230ms, total
1290ms -> 2500ms). The wanted rows are exactly (sample_id, 0), so resolve their
positions with Index.get_indexer instead. Falls back to the original scan when
the index is not unique.

Measured on the UltraEdit harness (859M params, batch 24, A10G, 3.96M samples):

    signals   1230ms -> 28-41ms
    total     2500ms -> 1310-1338ms   (1171ms with weightslab stubbed out)

NB_SEEN now actually increments (it was stuck at 0 before dev's fix), verified
against the ledger: rows with nb_seen>0 equals rows with last_seen>=0.

The UI contract suite passes 21/21 on this build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(data_service): bin the numeric histogram over the whole view again

Each bar must cover total_rows / max_bins samples so the chart carries
density: a column that is only 0.2% populated should show a few filled
bars and the rest empty. Binning over just the rows that carry a value
made the chart look equally full at any coverage, which reads as "every
sample already has a loss".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* proto: regenerate with package-relative imports after the dev merge

dev checks in generated code that does a flat 'import experiment_service_pb2',
which only resolves if weightslab/proto is itself on sys.path. Imported as a
package -- which is how the trainer loads it -- startup dies with
ModuleNotFoundError. Regenerated from the merged .proto at the repo root so
dev's new RPCs are kept and the import is package-relative again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(logger): import deque alongside defaultdict

The merge re-applied our in-memory history tail onto dev logger.py, which
imports only defaultdict. Every per-sample write then raised NameError inside
_stage_sample_row. The caller swallows per-signal exceptions, so nothing
crashed: the tail just stayed empty, sig/loss_debiased failed every step, and
loss_shape had no history to classify.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(shapes): keep the label cache on top of dev write_signal_shapes

dev rewrite keeps the O(change) read and adds exp_hash scoping, both kept.
What it dropped is the label cache, which two behaviours depended on:

  - an incremental pass still returns a distribution over the WHOLE dataset,
    not just the samples it happened to touch;
  - a sample whose label did not change is not re-written to the ledger.

Both are asserted by e2e_autotag (distribution_covers_dataset,
incremental_writes_bounded), which failed on the merge until this went back.
The test also moves to dev parameter name, sample_ids.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(signals): restore inputs= on the batched subscribe_to path

On the subscribe_to path BatchSignalContext was built without inputs=, so
b.inputs was {} and any signal declaring inputs=[...] raised KeyError on every
call. sig/loss_debiased does exactly that: it failed 12,079 times in one five
hour run -- once per step -- and because wrappered_fwd swallows per-signal
exceptions nothing crashed, the column just silently never got values.

We had already fixed this; taking dev src.py whole during the merge reverted
it, since dev never carried the fix. Same class as the deque import and the
label cache: dev has no equivalent, so a wholesale take drops it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Remove optrace tracing from weightslab

Drops the optrace module and every call site: 64 @Traced decorators, 13 hit()
markers and 5 imports across the data stores, the dataframe manager and the
two services.

Pure deletion -- 437 lines out, 0 in. Every hit() was verified to be a bare
statement rather than an expression, so removing it cannot change a value, and
removal was parenthesis-balanced because several spanned three lines. Each
file is compiled after editing, which is what would catch a removal that left
an empty block.

The tracing was built to find where interactivity time went on a 100GB
dataset. It has served that purpose: the O(change) view sync, the O(batch)
NB_SEEN lookup and the flush accounting all came out of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci: fix the code-quality and gRPC-test failures on this branch

Three fixes, one per failing check.

ruff F841, trainer_tools.process_sample: the positional unpack of
_getitem_raw bound _res[1] to idx, which nothing reads -- the function
returns sid. Dropped.

ruff F401, examples/.../wl-video-generation/utils/data.py: unused `os`
import. Pre-existing on dev and untouched by this branch; it only surfaces
here because the lint step appends ./weightslab to the changed-file list,
so ruff scans the whole package on any PR that touches it. Removing it is
what unblocks the gate.

AttributeError in tests/gRPC/test_grpc_user_actions.py:
_fastUpdateInternals duck-types take_view_dirty and get_source_rows on the
df manager. Both are new on this branch, so _FakeDFManager -- and any
third-party manager -- raised AttributeError instead of taking the
fallback. Guarded: a manager without dirty tracking cannot serve a delta,
which is the same "structural change" case the method already falls back
on, and the caller then runs _slowUpdateInternals exactly as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHV8zUd5aCtHtQourmwUJC

* fix(agent): one shared OpenCode model for the studio, the CLI and the backend

The backend answered on whatever model it resolved at start-up, and nothing
could move it afterwards: a model picked in the studio was ignored, `agent
model X` reported "Model switched to <the old one>", and `agent status` named a
model that was no longer in use.

OpenCode's own config (GET /config) is now the single shared choice, with:

  1. OPENCODE_MODEL      -- a hard pin, for automation. Never overridden; if the
                            studio disagrees, `agent status` says so.
  2. GET /config's model -- the live shared choice, RE-READ BEFORE EVERY TURN
                            rather than latched at start-up.
  3. agent_config.yaml's opencode_model -- a SEED: used when nothing has been
                            chosen yet, and published so the studio shows it.
                            It used to pin, so a run started after picking a
                            model in the studio went back to the yaml value.
  4. opencode/big-pickle -- the built-in default (was
                            opencode/deepseek-v4-flash-free), also published.

Whoever chooses last wins, and both surfaces follow. publish_model() writes
PATCH /global/config (falling back to /config) and CONFIRMS by reading back:
the workspace route answers 200 for a write it drops. An explicit switch
(`agent model`, `agent init --model`, the RPC) is published rather than
overwritten by the shared-config read, and current_model() re-resolves so
`agent status` reports the model the NEXT query will use.

The start-up banner now names the model actually in use and where it came from
instead of printing "(server default)" whenever nothing was pinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cli): hand the experiment directory to runs started from another terminal

`weightslab start` establishes the experiment directory and exports
WEIGHTSLAB_ROOT_LOG_DIR -- into its OWN process only. A training run launched
from a second terminal (or by `weightslab start example --seg`) is a different
process tree and never saw it: it fell through to tempfile.mkdtemp(), so the
run wrote reports/, notebooks/ and checkpoints into %TEMP%\tmpXXXXXXXX while
the UI listed an empty reports/ from the directory it had established. That is
the "right-click Generate report lists nothing, yet I generated reports" bug.

weightslab/utils/active_experiment.py records the directory in a small
per-user marker (~/.weightslab/active_experiment.json, WEIGHTSLAB_STATE_DIR to
relocate), with two independent sections: `ui` (what `weightslab start`
established) and `backend` (where training ACTUALLY resolved, whatever the
route). Both writes are best-effort and every read validates the directory
still exists.

* root_log_dir resolution gains a step: explicit config > env >
  the recorded `ui` directory > temp dir. The temp-dir case now warns loudly
  that the UI will not find the run's files.
* `weightslab start example` passes the recorded directory to the child, and
  says which directory it is using. Anything already set in the shell wins.
* The UI's reports/notebooks/agent listings follow a LIVE backend's own
  recorded directory, so they stay right even when training was pointed
  elsewhere by a config file. Only a live one counts: the marker outlives the
  process that wrote it, and a finished run must not hijack the listing of a
  UI that was given its own directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(ui): proxy the shared agent model same-origin

The studio picker wrote OpenCode's shared model directly from the browser,
which only works while OpenCode's --cors allowlist contains the page's exact
origin. A LAN address, a tunnel hostname, or an `opencode serve` started by
hand with no --cors all make that cross-origin PATCH fail its preflight, so the
pick never reached OpenCode -- and therefore never reached weightslab's
backend, which reads the same field to choose the model for its own queries.

GET/POST /agent-server/model proxy it through this server instead: same-origin
for the page, plain HTTP to OpenCode on the machine they share. The write goes
to the global scope first and is confirmed by reading /config back, because the
workspace route answers 200 for a write it drops. A transport failure or an
error status is reported as ok:false rather than "nothing configured", so the
page never shows its own default over the model actually in use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(data): stop a missing flag reading as a set one (phantom discards)

On a detection/segmentation run, sample after sample greyed out as
"discarded" in the studio as the model worked through the dataset -- while the
dataframe said nothing was discarded. It came down to one line of Python
semantics: bool(float("nan")) is True.

The chain:

1. the trainer touches a sample -> its rows go dirty;
2. _fastUpdateInternals syncs the trainer-owned columns (signals*, last_seen,
   discarded, prediction, target) from the ledger into the served view. It
   collapsed the per-annotation rows with duplicated(keep="last"), keeping the
   LAST annotation row -- whose sample-level columns are NaN, because the real
   values live on the canonical row (annotation_id == 0, which is what the view
   itself is built from);
3. NaN therefore landed in the view's `discarded` (and in `prediction` /
   `target`, and `last_seen` went stale);
4. GetDataSamples served it as "1" if bool(value) else "0".

Only annotation-expanded ledgers, only samples training had touched.

Fixed in three places, plus the two siblings of the same bug found while
auditing the function:

* the differential sync now takes the canonical annotation_id == 0 row
  (falling back to the first occurrence), matching how the view is built;
* is_set_flag / set_flag_mask replace bool() / astype(bool) wherever a nullable
  flag is read: the `discarded` rendering flag, the boolean tag:* columns in
  the metadata response -- where astype(bool) turned the NaN of every UNtagged
  sample into True, i.e. every sample wearing every tag -- and the histogram's
  per-(origin, discarded) split. They also read the strings "True"/"False"
  correctly, which a column that has been through the H5 store (categorical)
  can hold, and where bool("False") is True as well;
* the sync's position lookup no longer searches the view's sample_id level,
  which raises InvalidIndexError as soon as one sample_id appears under two
  origins -- the very thing the view's (origin, sample_id) index exists to
  allow. It failed on every call there and silently fell back to the full
  rebuild.

13 tests reproduce the chain and each sibling; every one of them fails against
the previous code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cli): only a running `weightslab start` hands its experiment dir over

The marker outlives the process that wrote it, so a directory recorded by a UI
that had since exited redirected unrelated runs. It bit this repo's own suite:
tests/gRPC/test_grpc_tag_operations.py resolved its root_log_dir into a
previous session's experiment, found the segmentation example's config and
checkpoints there, and errored in setUp with an unrelated config.

* the handoff now reads live_ui_experiment_dir(), which requires the recording
  process to still be alive -- which is what "the UI is up over there, put this
  run in its experiment" actually means;
* tests/conftest.py points WEIGHTSLAB_STATE_DIR at a throwaway directory for
  the whole session, so no test ever reads (or writes) the developer's own
  WeightsLab state, whatever a future one happens to resolve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(data): default `discarded` instead of leaving it NaN

SampleStats.DEFAULTS documents `discarded` as False, directly under the
comment "None are not accepted by PD H5 storage" -- so a NaN in it was already
a broken contract, and it is where the phantom-discard bug started.

The existing normalisation could not catch it: it only visits columns an
upsert ADDS (`missing_cols`), and only when the incoming slice's dtype is
already bool -- which it is not exactly when the slice carries missing values.
So a sample registered without the flag, and every per-annotation row
(sample-level values live on annotation 0), kept a NaN, and bool(NaN) is True.

_fill_documented_flag_defaults() gives every column with a boolean default in
SampleStats.DEFAULTS its default after each upsert. An isna().any()
short-circuit per column means the common case touches no rows; a categorical
column (what the H5 store hands back) is widened first, since fillna on a
Categorical raises for a value outside its categories.

Deliberately NOT applied to tag:* columns: for a boolean tag, NaN and False
mean the same thing and NaN costs nothing, and for a categorical tag NaN means
"unset", which is not a default at all. The read side (set_flag_mask) already
treats both as not-set.

Belt and braces with 4819483: the flag is defaulted at the source AND a NaN
that reaches a reader anyway is read as not-set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Alexandru Rotaru <rotarualexandruandrei94@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Guillaume <guillaume@graybx.com>
Both pairs were test bugs, not defects in the code under test.

tests/test_opencode_binary.py -- resolve_opencode_argv() returns str(Path), so
the expected value has to be spelled the same way: str(Path("/mgd/opencode"))
is "/mgd/opencode" on POSIX and "\mgd\opencode" on Windows. The POSIX form was
hard-coded, so `test_managed_present_wins` and `test_download_when_no_path`
failed on Windows only. Compare against str(Path(...)).

tests/general/test_four_way_standalone.py -- `set_hp` refuses to guess when the
process holds more than one hyperparam set ("Multiple hyperparam sets present;
provide hp_name explicitly"), and the ledger is global: the sets registered by
the other levels in this file, and by any module that ran earlier in the same
process, are still there. So the two set_hp tests passed or failed depending on
what had run before them. They now name their set via resolve_hp_name(),
exactly as test_hp_lists_and_shows in the same file already does for `hp` --
and which its own comment explains was added for this reason.

Full suite as CI runs it (pytest ./tests -m "not scale"): 1901 passed,
145 skipped, 17 deselected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A dataset declaring media_kind="video" with a non-generation task_type (a
video *classifier*) rendered as a still image with no player: the poster
frame went through is_video_sample(), which honors media_kind, but the
media_info stat — what tells the grid to badge a cell and the modal to offer
the player — was gated on is_video_task(), true only for "video_generation".
GetMedia could stream the clip the whole time; nothing advertised it.

Adds has_playable_media(dataset, task_type) and uses it at the media_info
site. Setting task_type="video_generation" instead would have been a one-line
"fix" that silently broke the labels: trainer_tools only extracts cls_label
for task_type == "classification".

Also sizes the encoded-clip LRU for actual browsing. It was 3 entries,
hardcoded, sized for "the sample on screen" — smaller than the studio's
6-neighbour modal prefetch window, so the window evicted its own entries and
every step through a video dataset re-muxed clips that had just been fetched.
Now 32 entries bounded by a 512 MB budget (WL_MEDIA_CACHE_ENTRIES / _MB),
since clip sizes vary by orders of magnitude between datasets and the count
alone cannot bound memory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mangling detection boxes

Video segmentation and detection annotate every frame, but the grid and the
modal's still view draw exactly one — the middle frame, which is what
video_poster_frame renders. Sending the whole stack did not fail loudly: a
[T,H,W] mask got RLE'd into a stat the renderer reads as 2-D, producing an
overlay that looked like an overlay and described a different moment.

Adds video_utils.select_frame_annotation(), applied to GT and predictions on
every path that encodes them. It deliberately does NOT slice [H,W,C] (a
colour mask is 3-D but not per-frame) and is a no-op at T<=1, so image
datasets are untouched.

While wiring it, two pre-existing preview-cache defects surfaced. Both share
a signature: the FIRST fetch of a sample is correct (it comes from
_process_sample_row) and every fetch after it is wrong (served from the
cache).

  * Plain 2-D detection had no branch at all in
    _refresh_preview_masks_from_row — only detection_pointcloud did — so a
    [N,6] box array fell through to the segmentation encoder and was
    PIL-resized into a 64x64 RLE "mask". This affected IMAGE detection too,
    not just video. Adds _refresh_preview_boxes_2d_from_row and a shared
    _boxes_to_bbox_payload so the two paths cannot drift again.
  * The preview encoder's segmentation branch reads a 3-D array as [H,W,C]
    and takes [:, :, 0]; on a [T,H,W] stack that is a frames x height strip.

Verified against a generator with exact ground truth: the mask the studio
receives is pixel-exact against frame 4 of 8 AND frame 4 is the best-matching
frame, which is what rules out an accidental match. The regression test for
this class of bug is "query the same sample three times and assert the
answers agree" — one query passes even with a poisoned cache.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ng the run

Two failures from the same run, both in wl's default evaluation.

1. Every batch logged "Expected all tensors to be on the same device, but
   found at least two devices, cuda:0 and cpu!". Device resolution read the
   first parameter and swallowed EVERY failure, returning None — and a None
   device means the batch is never moved at all. Now falls back through
   buffers (a module can be all-buffers, and a proxy may forward neither) to
   the accelerator, so the answer is CUDA whenever CUDA is in play, re-homes
   the module onto it (non-persistent buffers are absent from a state_dict, so
   a checkpoint restore can strand them on CPU), and moves inputs/targets
   recursively — a top-level hasattr(x, "to") left every tensor in a
   tuple/list/dict behind.

2. train -> pause -> evaluate -> train died with a CUDA device-side assert,
   surfacing on the next `clips.to(device)`, miles from the cause. Signals are
   called with the model's RAW output, and a binary metric handed
   [B, num_classes] indexes out of bounds: on CPU an IndexError the caller
   catches, on CUDA a device-side assert that poisons the context for the
   whole process. Worth noting the failure is DATA-dependent, not shape-
   dependent — BinaryAUROC given [B, 2] raises for some values and silently
   returns a wrong number for others — which is why it appeared after one
   particular evaluation. A signal that raises is now retired for the rest of
   the pass instead of being re-called once per batch, and the warning names
   the signal and both shapes so the contract is visible from the log.

Also adds the get_poster_frame() hook: the grid draws one frame per sample,
and asking the dataset for exactly that frame instead of loading the whole
clip took a 24-cell page from 8.0s to 4.6s cold (server-side image time
1153ms -> 607ms per sample). Datasets that do not define it are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing reported which model was ACTIVE. AgentHealthResponse was
{available, message} and GetAgentModelsResponse was {success, models[],
message} — a list of what you COULD pick, never what is picked. So the studio
knew only the model it had set itself, and a `/model` switch from the CLI had
no channel to reach it at all.

Adds `current_model` to both (additive fields, so older clients are
unaffected), resolved through agent.current_model(), which re-reads OpenCode's
config rather than a snapshot taken when the provider was last initialised —
OpenCode's config is the shared state both surfaces already write to.

Health is polled, which makes it the right carrier: the studio notices a
model chosen elsewhere without anyone opening the picker. The model list
reports it too, so the picker opens on what is actually in use.

_current_model() never raises: health is polled constantly and one
unresolvable model must not take the whole response down.

Note for whoever regenerates next: protoc must run as
`-I . --python_out=. weightslab/proto/experiment_service.proto` from the repo
root. Running it with `-I weightslab/proto` emits a bare
`import experiment_service_pb2`, which breaks every importer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… a model

Diagnosed live: two OpenCode servers were running with DIFFERENT models —
127.0.0.1:51447 (spawned by the UI server) reporting
opencode/muse-spark-1.2-contributor-free, and 127.0.0.1:4096 (the backend's
OPENCODE_URL default) reporting opencode/nemotron-3-ultra-free. The studio's
picker wrote the first server's config; the training backend read the
second's. Neither side was broken; they were simply talking to different
servers, and nothing anywhere said so.

The rendezvous is the per-workspace lock file, which only works when both
halves agree on a directory. They routinely do not: `weightslab start` with
no argument generates its own workspace (…/ui/utils/wl-calm-cedar in the
observed case) while the backend falls back to WEIGHTSLAB_ROOT_LOG_DIR or its
cwd. Each found no lock and spawned its own server.

Adds a machine-wide lock (~/.weightslab/.wl_opencode.json), published
alongside every workspace lock and consulted after the workspace lock and
before spawning. A side that does not share the workspace now adopts the
running server instead of starting a second one, and re-publishes it under
its own workspace so the next lookup is direct.

Machine-wide rather than per-project on purpose: OpenCode is a single local
server by design. OPENCODE_URL still wins outright — an operator who set it
opted out of discovery — and an unhealthy address is never adopted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A parametric-UMAP encoder trains alongside the model on the input of its
last parameterised layer -- the representation feeding the head, never the
logits -- and writes three coordinates per sample. On by default, no code:
wrapping a model with flag="model" installs the hook, and every supported
way of producing a per-sample value (a flag="loss" criterion, or a bare
save_signals) is where features and sample ids get paired.

Features are detached and the encoder carries its own optimizer, so it
cannot perturb training. Eval batches are placed but never fit on, so the
layout never learns from data the model was only scored on.

Level of detail is server-side: GetProjection takes the client's view box
and a render budget, and decimates what falls inside the box down to it.
Zooming shrinks the box, so the same budget buys finer detail and a
multi-million-sample cloud never crosses the wire whole. Coordinates come
back as packed parallel arrays, which is both cheaper than one message per
point and the exact shape a BufferGeometry wants.

The encoder rides alongside each checkpoint in a projection/ subdirectory
-- not beside it, where it would match the weight-checkpoint glob and break
checkpoint selection. Pairing them per checkpoint is what makes a restore
coherent: old features read through a newer map would draw a layout that
never existed.

wl.project_dataset() re-projects offline from a restored checkpoint under
its own prefix, for when the auto-picked layer turns out to be the wrong
one; prefixes coexist and the board's picker switches between them.

Also in save_signals, since it is the same code path: a signal value of an
unsupported type now raises instead of being dropped. A dropped value
created the dataframe column and then never wrote to it, so the metric
stayed empty forever with nothing anywhere saying why. numpy arrays, which
used to fall into exactly that hole, are now first-class.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GenerateNotebookCode was a plain blocking handler. Refreshing the studio
cancelled the grpc-web request, but gRPC never interrupts a running sync
handler: OpenCode finished the work, the audit log recorded a success, and
the generated code went to a stream nobody was reading. It was unrecoverable
too -- OpenCodeChat opens a fresh session per call, and agent.history keeps
breadcrumbs, not the code.

The work now runs on its own thread behind a Future, keyed by the hash of
(prompt, context_code). The reloaded page replays the identical request, so
it either attaches to the still-running generation or collects the finished
result at once. A failure drops its entry so a retry retries rather than
replaying the same error; successes expire after _GEN_CACHE_TTL_S so a stale
key can never shadow a genuine re-ask. The audit happens once per
generation, in the thread that produced it, not once per client collecting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cancelling the timer only prevented future ticks. A check-in already inside
_opencode_send_and_collect kept going to its own timeout, so closing a
loop's tab left the agent working -- burning tokens on an answer whose tab
no longer exists, and holding open a session the registry then leaked.

Stop now aborts the running turn and deletes the session. Both are
best-effort behind a short timeout (WL_LOOP_STOP_TIMEOUT_S, 5s), because the
stop button waits on them and a wedged OpenCode must not hang the UI. A turn
that refuses to abort stops mattering anyway: the job is already out of the
registry, so its result is dropped and no further tick is scheduled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Regression coverage for 0d1ed61, which put the eval batch where the model
is: a model moved to cuda while the loader still yields cpu tensors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test_grpc_serve_honors_explicit_port_without_force_parameters timed out on
commit ac40533's PR run (35738307562) and passed on its push run
(35738302340) -- same commit, same workflow.

It was never deadlocked. serving_thread_callback logs its way through the
bind, and in a full-suite run each of those calls takes SECONDS:

    00:35:29.215  [gRPC] Thread callback started
    00:35:39.294  [gRPC] Creating ThreadPoolExecutor   <- +10.1s
    00:35:44.893  [gRPC] Server object created         <- +5.6s

Three log lines, 15 seconds. Every assertion would have passed; the test just
never reached them before _TimeoutMixin fired -- hence a timeout rather than a
failure, and a green rerun every time. The contention comes from threads
earlier tests start and never stop (the embedded ipykernel's tornado/zmq loop,
dataframe_manager's flush threads and their DEBUG chatter): by the time pytest
reaches tests/trainer/ they hold the logging lock often enough to starve it.
tests/trainer/services on its own passes -- 386 passed.

- stub the module logger in _run_grpc_serve_capturing_bind. These tests assert
  on add_insecure_port and have no interest in log output, so the fix is to
  stop depending on real logging rather than to outwait it.
- _TimeoutMixin built its exc_info with a None traceback, so pytest raised
  "'NoneType' object is not iterable" while rendering and fell back to
  "Incompatible Exception Representation" -- the report named no location at
  all. Raise and catch instead, for a real traceback.
- 30s -> 60s: the cap guards against a stuck thread, it shouldn't double as a
  performance assertion. This alone did NOT fix the hang.
- conftest: keep the real ResourceMonitor out of the suite. grpc_serve ends by
  calling start_resource_monitor_from_config() and nothing stubbed it, so the
  first such test left a process-wide singleton sampling CPU/memory/disk/
  network/GPU for the rest of the session. One of the leaked threads above.

Full suite: 1847 passed, 145 skipped, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI died on the way out of a run whose tests had all passed:

    Fatal Python error: _enter_buffered_busy: could not acquire lock for
    <_io.BufferedWriter name='<stderr>'> at interpreter shutdown, possibly
    due to daemon threads
    ... Aborted (core dumped)

The embedded Jupyter kernel runs as a daemon thread on app.start() -- a
tornado/zmq loop that never returns -- and had no shutdown path at all. So it
was still live when CPython began finalizing, and the shutdown raced itself:
the interpreter closes the zmq sockets, tornado's zmqstream notices ("Got
events for stream ... attached to closed socket") and calls gen_log.warning,
and logging writes into a stderr buffer whose lock is already being torn down.
That aborts the process; the job then fails on the exit code even though the
suite passed.

Not test-only, which is why this is here and not in tests/conftest.py: any
script that enables the notebook takes the same risk at exit.

Stop the kernel from an atexit hook, which runs BEFORE finalization, so
app.start() returns and the thread exits while logging still works.

The loop to stop is not the obvious one. _run_embedded_kernel sets up an
asyncio loop for its thread, but ipykernel builds its own AsyncIOMainLoop
under app.io_loop and blocks on that instead -- stopping ours is a no-op
(measured: our loop ...692880 vs app's ...181904, thread still alive after a
10s join). Go through app.io_loop, whose add_callback is thread-safe, and join
with a bounded timeout so a stubborn kernel can't hang the process on the way
out.

Verified: thread alive -> not alive across the hook; repro exits 0 with zero
"Fatal Python error". Note the abort does not reproduce on Windows -- it is a
platform- and timing-dependent finalization race -- so what is proven locally
is the mechanism, not the end-to-end CI symptom.

Full suite unchanged: 1847 passed, 145 skipped, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
guillaume-byte and others added 18 commits October 6, 2026 18:12
…feature/parametric-umap

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- ProjectionIndex: a stratified priority order plus a spatial grid, so a
  view (frustum-culled) returns its N points in ~0.1 s from 1M to 10M
  samples. Rebuilt in the background when coordinates move, reusing the
  order when only positions changed.
- Server-side lasso over every sample inside the loop (replace, add,
  refine), kept by token and applied to the data view with
  @projection_selection. Responses flag which drawn points are selected.
- wl.save_projection_coords and project_dataset(method=...) for t-SNE,
  PCA or any algorithm; a registry of which prefixes are projections; and
  custom projections saved and restored with each checkpoint.
- Running a notebook cell pauses training first.
- wl-mnist-at-scale: a simulated 1M MNIST run (no training) with
  predictions, a projection and train/val/test curves, to try the Studio
  at scale. Fashion-MNIST UMAP and custom-projection examples.
- Release workflow syncs weightslab/VERSION. Docs and tests to match.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- PauseController resolves the checkpoint manager on every use. It kept
  the first one it saw, so after ledgers.clear_all() a new experiment's
  checkpoints went into the previous experiment's manifest, and a reload
  by the current hash found nothing.
- Ledger.clear() drops src's cached dataframe handle, as wl.clear_all()
  already did. After ledgers.clear_all(), save_signals kept writing to
  the orphaned dataframe and the new run's table stayed empty.
- The OpenCode and Jupyter servers are spawned with stdin=DEVNULL.
  Inheriting the parent's stdin fails on Windows when there is none
  usable (a Jupyter kernel, pythonw, a background task): WinError 6/50.
- Tests that spawn fake OpenCode servers get their own machine-wide lock
  instead of ~/.weightslab, where they adopted each other's servers and
  overwrote the developer's own lock.
- src.py: drop two unused imports and mark the two public re-exports
  (ruff F401, which the CI lint gate enforces).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* projection: make the live UMAP work on Ultralytics, and name its layer

The YOLO example never placed a single point. Three causes:

- Fits failed under the caller's no_grad. The Ultralytics integration
  ships every per-sample signal inside torch.no_grad(), and the encoder
  fit inherited it: "element 0 of tensors does not require grad", five
  times, then the projection turned itself off. _fit now re-enables
  grad (and leaves inference_mode) for its own encoder, and the encoder
  is built outside inference_mode.
- The auto-pick hooked YOLO's DFL box decoder (model.23.dfl.conv): the
  last conv defined, frozen, and only run at inference. Frozen layers
  are now skipped while a trainable one exists, and WLAwareTrainer hooks
  model.model[-2], the P5 neck output that UL's own YOLO.embed() pools.
- Validation runs on UL's EMA model, built in _setup_train before
  on_train_start wraps the model, so it had no hook. mirror_projection()
  feeds the live tracker from such a copy too.

The board now says which layer it is a picture of: ProjectionResponse
gains layer / layer_detail ("model.22", "C3k2 output"), from the live
tracker for its own prefix and from the prefix registry otherwise
(project_dataset records the layer it read; prefixes.json becomes
{prefix: info}, the old list still reads).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* notebook: a default cell for the model's layers and the projection's

The notebook a new experiment starts with gains a second worked cell:

a) every layer of the model, by index and name, with its type and own
   parameter count, the one the live projection reads marked;
b) re-projecting from another: set LAYER to a name or an index from that
   list and run the cell again. It calls wl.project_dataset(...,
   layer=...) on the live prefix, so training carries on from that fit,
   and shows how to keep the layer for the next run
   (projection={"layer": ...}).

Inert until LAYER is set: re-projecting reads a whole split, which no
"run all" should start by itself. The blank agent cell stays last.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* projection: WEIGHTSLAB_PROJECTION_MAX_POINTS sets the points per view

The board's render budget was a browser setting (localStorage, 50,000 by
default), so it could only be changed one browser at a time. It is now the
server's: WEIGHTSLAB_PROJECTION_MAX_POINTS in the training process's
environment, 70,000 by default, capped at 400,000. It is read on every
request, so setting it from the Studio notebook applies at the next camera
move; a value that is not a positive integer falls back with one warning.

A browser can still override it for itself; the board now leaves the number
to the server unless it does. Documented in projection, configuration and
the board's level-of-detail section.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* dependencies: export with the classic ONNX exporter on torch 2.9

torch 2.9 made the dynamo exporter the default for torch.onnx.export. It
ignores training= and do_constant_folding=, and optimizes the graph, folding
a BatchNorm into the conv before it, so the dependency walk no longer saw
the layers it reads: a grouped conv's constraint stopped reaching its BN
(test_grouped_conv_propagation, wherever onnxscript is installed). Pin
dynamo=False where torch has the argument (2.5+); older torch is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* data_service: drop the unused is_video_task import (ruff F401)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* data: count a loader's for-loops, so one ending cannot make another endless

`is_a_loop` on a loader says some `for batch in loader:` is iterating it,
so the epoch must end with StopIteration instead of silently restarting.
It was a single flag, and two loops can iterate one loader at once: the
training script's own test pass, paused mid-epoch, and an evaluation the
Studio triggers on the same split. The evaluation finished first and
cleared the flag under the paused loop; that loop then never saw its epoch
end, the script's test() never returned, and training froze. It showed as
the classification e2e "train for N steps" never pausing on its own,
after a train-split then test-split evaluation.

The flag is now a count of active loops, under a lock. Each proxy iterator
releases its own hold exactly once: at StopIteration, when abandoned, or
when the evaluation wrapper stops early (close() instead of clearing the
flag outright).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* projection: an edit in the grid reaches the cloud within seconds

While the data view is sorted or filtered, the whole-dataset cloud is
drawn from a pulled copy of the data, re-pulled at most every 60 s. A
sample discarded in the grid therefore stayed un-greyed in the projection
for up to a minute: the classification e2e "Discarding in the grid marks
the same points in the projection" timed out after its 60 s poll, since
earlier tests leave the grid sorted.

ProjectionCache.note_edit() marks every cached index stale, so the next
request refreshes it (in the background, as before). EditDataSample calls
it after every successful edit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… its folder

The v2.0.3.dev0 release run (python -m unittest discover) failed with 56
errors:

- 22: weightslab/opencode_process.py and weightslab/opencode_binary.py were
  removed with the artefact files, but they are product code:
  weightslab/ui/server.py imports opencode_process at load (so `weightslab
  start` itself failed to import), the agent's opencode_chat spawns through
  it, and the CLI provisions the binary through opencode_binary. Restored,
  together with scripts/ci/agent_smoke.py, which ci.yml's agent smoke job
  runs.
- 31: a test moved the session log into its temporary root_log_dir and then
  deleted it. FileHandler.emit reopens a closed stream outside its own
  try/except, so every later log call in the process raised
  FileNotFoundError (tornado creating an event loop, in the notebook
  tests). The session handler now recreates the folder when it can, and
  otherwise reports through handleError: logging never fails the caller.
- 3: TestUltralyticsLayer skipped with pytest.importorskip, which bare
  unittest reports as an error. It now uses unittest.skipUnless.

The previously failing modules, run under unittest in one process with the
folder-deleting test first: 422 tests, OK.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The packaged weightslab/VERSION was whatever the tagged commit held (2.0.2
in the 2.0.3.dev0 release), since only the post-release sync updated it,
and only on the branches. Each build job now writes the version it is about
to publish first: TestPyPI's commit-stamped one, PyPI's tag version.

The PyPI build is pinned to that version too: writing the file dirties the
checkout, and setuptools_scm's guess-next-dev would otherwise build a dirty
tag as the next dev version (2.0.4.dev0) instead of the tag's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Cuts a dev build that bundles the current weights_studio dev branch
(projection board: colour legend, eye to isolate a selection, Overview keeps
the projection in place; list mode: text inputs shown as text; metadata UI
no longer lists projection coordinates). No weightslab code changes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Cuts a dev build that bundles the current weights_studio dev branch
(list mode: resizing a column changes only that column, hover spans the whole
row; grid cells show text inputs; Overview keeps the projection in place).
No weightslab code changes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Cuts a dev build that bundles the current weights_studio dev branch
(list mode: the scroll position is kept across the periodic refresh of the same
page; resizing a column changes only that column). No weightslab code changes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The README links the sandbox again (8c52723), so the check that it
never does fails on dev. Remove it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…warnings

CI aborted after every test had passed ("terminate called without an
active exception", core dumped). The embedded kernel's atexit hook was
registered before app.initialize(), which registers ipykernel's own
app.close; atexit runs the newest hook first, so app.close closed the zmq
sockets under the still-running kernel loop. The kernel thread now
registers the hook right after initialize(), and the hook also removes
ipykernel's gc callback, which errored during interpreter finalization.

Warnings, fixed at the source: notifyAll -> notify_all; Image.fromarray
without the deprecated mode= (arrays are already uint8; RGB is enforced
with .convert where a hook may return 4 channels); IPython colors
"nocolor"; the CLI server and client threads end quietly when the socket
closes or the client disconnects; loss.item() in a test. Filtered in
pytest: PyTables NaturalNameWarning (pytest resets the filter
h5_dataframe_store installs), Lightning's num_workers hint, and
ipykernel's own DeprecationWarnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
github-actions Bot and others added 2 commits October 14, 2026 15:03
…project

The auto-pick took the last nn.Linear anywhere in the model before any
conv. PointPillars (wl-2d/3d-lidar-detection) has exactly one Linear, its
per-point feature net, registered first and run on every point of the batch
at once, and a conv head: the hook read (points, C) -- 6000 rows for a
batch of 3 -- so every batch failed the one-row-per-sample check and was
dropped without a word. The board never got a point.

The pick now follows its own docstring: the INPUT of the last Linear or
conv in registration order, whichever comes later (aux and frozen layers
still skipped). The LiDAR examples hook `head` (Conv2d input, one row per
sample): 12 fits and 5640 coordinates in 120 steps of the 2D example, where
dev had 0. Baseline zoo: every model still yields (B, F); only
TwoLayerUnflattenNet moves, to its final conv (the real last layer).

A hooked layer that never sees one row per sample now says so: a warning
after 3 such batches, `mismatched_batches` in stats(), and the board's "No
projection found" reason names the layer and the row count instead of
promising a first fit that cannot happen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
github-pages — 1eaec857 Deployed Oct 16, 2026 by guillaume-byte via deploy #725
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Projection for data exploration

2 participants