Skip to content

Consolidate cellmap-flow: one contract, one source for shared data, the confirmed bugs, fewer tests - #150

Merged
davidackerman merged 42 commits into
mainfrom
consolidate
Oct 22, 2026
Merged

davidackerman merged 42 commits into
mainfrom
consolidate

Conversation

@davidackerman

@davidackerman davidackerman commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

One PR for the consolidation of cellmap-flow, built the way #103 was: each item lands on its own branch and is merged here with a merge commit, and this description grows with it. The review behind it measured the repo at 130k lines (47.5k Python, 25k browser TypeScript, 11.5k dashboard JS and templates, 47k tests), found about 10.5k lines of TypeScript that repeat Python logic with about 90 places where the two copies disagree, and set three waves of work: (A) the confirmed bugs, the contract generated from the pydantic models, the constants both modes copy by hand turned into data, and the test suite cut to what protects behaviour; (B) the numeric core written once in Python and run in the browser through Pyodide, the Python joints (one way to add a model, one TrainingOptions), the 0.3.0 shims; (C) decided later. The plan and status live in consolidation_review/PLAN.md on the author's checkout.

Merged so far

Fix four confirmed bugs found in the dashboard review (fix-confirmed-bugs)

The browser ranked a sigmoid or tanh model as if its output were logits. web/src/backend/routes/rank.ts read every served model's raw output straight into the tally, where the server's twin (rank_routes._score) reads it as a probability by the model's output class, clipped, then its logit. A model whose manifest says it outputs probabilities had 0.1 read as "object": it scored half at 0 and its cut-off, which Use this model makes the seed threshold, landed between its two values. The route now converts with label-it's probability() and the served model's output_class; a logit model's score is unchanged within float32. The test fakes' served ModelInfo carries the manifest's output class, as the real one does.

A layer's URL now carries the dashboard's address and the chain's digest however its model was started. Only Submit's redraw (PUT /api/pipeline) put them in; startup, Submit's launches, Add a model, Use this model and a finetune's layer built the blob from the live steps alone. So an inference server running a merging postprocessor never posted its merged ids for such a layer (chunkmirage_ops._send_equivalences returns without a dashboard URL), and the first PUT of an unchanged chain redrew every layer because _shows() never matched. One helper, Session.layer_blob(), builds the blob from session.pipeline_spec with the address and digest, and every path uses it. The dashboard binds its port before the startup viewer's layers are built (bind_dashboard; create_and_run_app takes the bound server), so those carry the address too. The address is the dashboard's own, by IP, which a server on the cluster can reach, rather than the host the browser's request came by (a proxy's, or localhost over a tunnel); a request's host_url remains the fallback when none is bound. The layer snapshot tests pin what the layers now carry.

Export Config left a removed model in the YAML. The ✕ stopped the model and took its layer, but its config stayed among the session's models, so the exported YAML started it again with cellmap_flow yaml. remove_model now drops the config (drop_config, shared with Add a model and Use this model); a model unticked on Submit keeps its config for its row on the Models tab and the pipeline builder.

The two chunkmirage pins disagree, and are not bumped here. pyproject.toml pins 7331a3fc (3 Oct) and web/chunkmirage.lock d6fd94ae (16 Oct), 70 commits apart. A test (tests/web/test_pins.py) now asserts they agree, so they move together from now on. It is xfail(strict=True) for the moment: at d6fd94ae chunkmirage moved Cast to chunkmirage_contrib.ops (a second package in the same wheel), and serving/chunkmirage_ops.py imports it from chunkmirage.ops, so bumping pyproject would break layer_ops. Every other name cellmap-flow imports still exists there with the same signature; this was checked statically against a local chunkmirage checkout, not run against a live model server. Bumping needs the import moved to chunkmirage_contrib.ops, pyproject set to d6fd94ae, the marker removed, and the server tests run in an environment with that chunkmirage.

Generate the dashboard's contract from the pydantic models, and read a blank training field as the form's default (spec-contracts)

The dashboard's JavaScript speaks to the Flask routes in server mode and to web/src/backend in browser mode, and nothing held the two to one contract: the route lists, request shapes and shared numbers were copied by hand, and a Flask route added without a browser twin became a silent 503.

cellmap_flow/schema.py is now the one place. It exports a JSON Schema (draft 2020-12) of every request model (dashboard/requests.py, plus ResolveModel/AddModel/PipelineUpdate), the table of every route with where it is answered — both, stub, server or browser; the method and path from the app's url_map, the availability declared beside it, the two cross-checked so a new route without a declaration fails by name — and the constants both modes share (the rank score's, Undo's depth, the AI annotation's caps, the Finetune form's defaults, the chain steps' parameters). npm run gen in web/ writes web/src/generated/cellmap-flow.schema.json, cellmap-flow.ts (types by json-schema-to-typescript 16.0.0, with the constants, ROUTES and the step parameters as values) and docs/api.md; npm run gen:check diffs them. Today: 53 routes in both modes (7 of them browser stubs), 36 server-only, 5 browser-only.

tests/web/test_schema.py fails when the generated files are stale, when a Flask route is added without a declaration, when the TypeScript router registers a route the table does not give the browser or misses one it does, and when the server form's defaults leave FinetuneSubmit's; web/test/unit/generated-routes.test.ts holds the router to ROUTES; CI's web job runs the check. The browser's DEFAULT_OPTIONS and LABEL_MAX_CHARS come from the generated file, and contracts.ts re-exports the request body types.

POST /api/finetune/submit read a field sent blank as 10 epochs, a batch of 2, MSE, LoRA rank 8, class balance off and distillation left to the trainer, while the Finetune tab shows 20, 8, margin, 64, on and 0.01; the defaults are the form's now, and the browser keeps only its own (the head's learning rate and BCE, no smoothing, one tile a step) as explicit overrides. One behaviour change for API callers that omit loss_type on a sparse session: they now train margin with λ = 0.01, what the form always sent, instead of the MSE-to-margin switch with λ = 0.5.

web/ARCHITECTURE.md states the rule this serves — one implementation per behaviour: a feature works in both modes only when its logic is written once (a shared Python core, chunkmirage, the dashboard JS, or generated contracts); otherwise the other mode answers unavailable — with the table of what is both-mode, browser-only and server-only. .github/copilot-instructions.md points at it.

Turn the constants and catalogs the browser app copied by hand from Python into data generated from Python (data-registries)

web/build/export_data.py writes web/src/generated/python-data.json from cellmap_flow. The file is committed, and npm run export:data and npm run render regenerate it. The app reads it through web/src/generated/python-data.ts instead of its own copies of: the normalizer and postprocessor registry, with each op's parameter defaults and whether the browser runs it; the default chain, which was written twice (build/render.py and src/state/index.ts); the organelle catalog, the prompt's sentences, and the AI-annotate limits, stage labels and key binding; the raw and prediction shaders, the prediction colours, the colour-control pattern and the layer keys tied to a source; the ranking's constants; the finetune jobs' terminal statuses, epoch patterns and restart marker; Label it's undo depth and large-box size; the unit table.

The app reads the JSON with the type TypeScript infers from it, so a field renamed in Python fails the typecheck where it is used. tests/web/test_python_data_fresh.py fails when the file is stale, and replaces test_layer_carry.py's regex scrape of viewer/state.ts.

Fixes, because the data now has one source: the browser page offers only the steps it runs (it listed FillHoles, CellposeMasks, ChannelSelector, Dilate and EuclideanDistance, then answered 400 when one was ticked; each op's Python class now declares browser = True when the browser runs it, render.py lists only those, and a vitest test holds that set equal to the ports in web/src/infer), and the server reads μm (Greek mu) and the British spellings micrometre, nanometre, millimetre, centimetre, metre, picometre as lengths (it took them for nanometres; the browser now reads the same table).

Cut the test suite from 46.6k lines to 27.1k (cut-tests)

The tests had grown to 46,644 hand-written lines. A large share were wording and shape pins, or the same behaviour tested several times over. The rule now is one behavioural test per user-facing feature, in each mode (server and browser), at its outermost layer; parity tests check what both modes must agree on.

before after
Python tests (tests/) 25,632 14,660
TypeScript tests (web/test/**/*.ts) 18,530 9,950
Fixture generators (web/test/fixtures/make_*.py) 2,482 2,482 (unchanged)
Hand-written total 46,644 27,092
JSON fixtures 11,034 lines 8,447 lines
pytest 2,240 passed, 161 s serial 1,116 passed, 31 s on 8 workers
vitest 78 files, 1,567 passed 59 files, 762 passed

What went. Wording and shape pins: the CLI surface table, frozen bsub command lines, the served-metadata and chunk hashes, the RNG draw sequence, the per-platform loss tables, weight hashes and checkpoint keys, the annotation-volume byte pins, the finetune routes' table of 60 exact error strings, the blockwise task YAMLs byte for byte, the browser page's element-id lists, the prompt text, and roughly 500 assertions on error sentences in both languages; where a pin guarded real behaviour the test now checks a status code and the kind of error, a keyword, or yaml.safe_load and a few keys. Duplicates: TypeScript unit tests that hand-copied Python's numbers for ops a parity test already covers (the args codec, labels, Morton ids, norms, dtype, output), TS tests that repeated another TS file, and Python files whose behaviour another test covers. Layers: the launch command was tested at six layers, finetune submit at four and restart in about eight places; each keeps its outermost behavioural test plus the restart token and Shift+R. The Hub's training-graph snapshots (2,587 lines of JSON) are replaced by a small graph built inside the test.

What stays on purpose. Every AI-annotate secrets and egress test (key redaction, key-file mode and owner, keys in browser memory only, the destination acknowledgement, the dataset allow-list); the safe-Lambda whitelist in both modes; test_deployability whole; every parity test, fixture and generator; test_fixtures_fresh, test_io_metadata, test_url_map, test_layer_carry, finetune-rank and the Playwright specs; the real failure modes once each (OOM retry, a NaN batch, restart, resume after a dashboard restart, a server announcing its address only once it answers, the bsub-timeout job id, SIGHUP cleanup). Thirty-seven files were deleted; the branch's commits name the remaining test that covers each feature.

Also: CI runs pytest in parallel (pytest-xdist in the test extra and pixi's test feature, -n auto in both pytest steps; pixi.lock refreshed with pixi lock, adding only pytest-xdist and execnet), and docs/source/architecture.rst describes the suite as it is now instead of naming the snapshot tests as refactor guards.

Left for a follow-up: two source comments that now promise more than the tests check (finetune/losses.py's docstring; the "order, statuses and words" notes in web/src/backend/routes/ai-annotate.ts and view-labels.ts), and two functions with no caller left (tailer.trainer_outputs_from_log, PlanePlan.from_dict).

One definition of the training settings, and a trainer in parts (training-options)

cellmap_flow/finetune/options.py's TrainingOptions holds every setting a finetune trains with and its default, once: the mode (lora, full, or the browser's head, replacing "a LoRA rank of 0 means full"), rank and alpha, epochs, batch, learning rate, loss, margin, smoothing, class balance, distillation weight and scope, augmentation, mixed precision, the target type, channel and offsets, the model's output class, and the manifest's patches per epoch and rehearsal fraction. The submit and restart bodies (FinetuneSubmit, FinetuneRestart), the job's command line (every flag made from the model's fields; the old flag names are kept, so a running job still takes a restart), the run's metadata, the trainer and the browser's generated FINETUNE_DEFAULTS are all read from it, where seven layers each listed the settings with defaults that had drifted. TrainingOptions.for_session is what submit adjusts for a painted session or a distance or flow target; a restart's values are checked as the form's are, and a rank change across kinds is refused up front.

The trainer reads the model's output as its server does: output_class (serving.probe's unit / signed_unit / unbounded) comes from the running model's model_info, else --output-class, else one probe in the job, replacing the trainer's own sigmoid probe (and its four cache helpers), which took a tanh's [-1, 1] for logits while ranking and seeding read it as (p + 1) / 2.

lora_trainer.py (1,516 lines) becomes finetune/trainer/: loop.py (Finetuner; LoRAFinetuner kept as its old name), supervised.py (one SupervisedLoss for the 7-way construction and 5-way dispatch), distillation.py, recovery.py (precision, OOM, finite gradients), diagnostics.py, tensorboard.py; the checkpoint files are written and read by adaptation.py's strategies, and BatchLoopWrapper/SequentialWrapper sit with the other wrappers in trainable.py. Behaviour is unchanged apart from the output-class reading; the existing finetune tests pass with import-path edits, plus one test for the single definition and one for the tanh model. Hand-run CLIs with no flags now get the form's defaults (rank 64, 20 epochs, margin, smoothing 0.1, balance on, λ 0.01); the job manager always passed them explicitly, so dashboard runs are unchanged.

One model entry, one constructor path, one launcher (model-entry)

Every description of a model is a model entry, {type, name, env, **arguments} (models/entry.py), and registry.build_model is the only place a config is built from one: a YAML's models:, cellmap_flow infer <type>'s options, the pipeline builder's model form (type; class_name deprecated for one release), the Models tab's catalog, Hugging Face, zoo and Cellpose picks, Add a model and serve --model. Values are coerced by the constructors' annotations (entry.coerce), replacing coerce_cli_args, coerce_form_params, build_model's shorthand and the constructors' own parsers, so the same entry gives the same config from the CLI, a YAML and the form; the two type tables are one lookup.

serving.launch.launch starts every server (infer, yaml, add --run, the Models tab, the Training tab's ranking): resolve_data_path(scale), the saved-settings fallback, start_hosts. This fixes the Models tab serving a config with scale at the session path. The dashboard's five run_* and four launch loops are one data-driven path over built configs; a catalog model's config now stays in the session (Export Config lists it), and a missing catalog folder is refused at Submit instead of failing on the node.

One bioimage entry builder and one voxel-size rule (the config's: given, else declared, else trained at); configs/bio and bioimage_catalog read io.metadata's unit table, so pm, cm and m models are accepted. The Models tab's JS is one module per list under dashboard/models/, each with payload() and untick(); there is no DOM harness in this environment, so a click through the Models tab in a browser is worth doing before merge.

Tests: one per new behaviour (the same entry three ways; the dashboard honours scale; the zoo accepts pm/cm/m); the launch tests patch jobs.launch.start_hosts alone. The legacy command/cellmap_flow_server paths are kept for the release that removes them.

The paint logic written once, in Python, run by the browser too (pyodide-pilot)

chunkmirage is pinned at 7307e36b for both modes (pyproject.toml and web/chunkmirage.lock), the commit that merged the one hook this needs upstream (JaneliaSciComp/chunkmirage#46: a page's own Python loaded into the engine's Pyodide workers and called by module path); Cast is imported from chunkmirage_contrib.ops, where chunkmirage moved it, and the pins test is no longer an xfail. The serving tests pass against that chunkmirage; no live model server on the cluster was run.

cellmap_flow/portable/ is the Python both modes run (standard library, numpy, scipy, pydantic, Pillow only; tests/portable/test_boundary.py enforces it): the rank score moved whole from finetune/rank.py (re-exported there) and the maths of Use this model's paint carry out of session/volume.carry_paint, which keeps the zarr reads and writes. On the way this fixed the server carrying a uint8 paint volume onto a uint16 one (numpy 2 refused the 65535 cap).

The browser bundles those files as text and runs them in chunkmirage's Pyodide worker: Inference.python is chunkmirage's Engine.call by module path. Pyodide and numpy load in the background when a dataset opens (one worker). web/src/finetune/rank.ts and carry.ts (383 lines) are deleted with their unit tests; the rank routes are tested on the same Python in node's Pyodide (pyodide@0.28.3; skipped without the network).

Measured on the built app in headless Chromium on a Linux workstation: Python ready 3.2 s after the dataset opens (3.7–4.3 s after page load; 10.2 MB over the wire, once per browser), first rank call 0.05 s, warm 0.02 s, a 32-chunk scoring loop 0.4 s. Not yet measured on a Mac.

The volume reader is worldarray (worldarray-trial, named before the trial succeeded)

worldarray 0.1.0 (PyPI, from tag v0.1.0 = 5e9d17d) is the shared tensorstore reader the architecture review asked for: placement in nm, level choice, padded and resampled reads, written once where seven of cellmap's repositories carried a diverged copy. ImageDataInterface now composes a WorldArray; pyproject.toml pins worldarray>=0.1,<1 and pixi.lock is refreshed. cellmap_flow/io/ goes from 2,201 to 1,059 lines (14 files, +499 −1,591 against the branch point, the lockfile among them) and keeps what is cellmap-flow's: the chain and channel classes, s<N> level names, path forms, the corner-floor ROI rule, ArrayMeta and its five users, io/paths, io/ome's writer. store.prepare and source.reader_context are gone. Browser app unchanged apart from the exported unit table.

Behaviour follows worldarray's spec (docs/spec.md there), and seven parity rows in tests/utils/test_io_metadata.py moved with it, each by up to half a voxel or a rounding step: COSEM translate and zarr v3 transform attributes are voxel centres (as OME translation already was); offsets are never rounded; a group's multiscales transform outranks an array's resolution; an N5 level with downsamplingFactors and no offset is placed as BigDataViewer places it (David confirmed); the nearest level on a tie is the finer; a resampling block cut short by the data's end is the mean of what it has. jrc_hela-2's N5 and OME-Zarr copies now give the same corner (checked against the real bucket). Four rounds of the trial fed worldarray's fixes (S3/GCS env, N5 string units, http ancestor probing, read overhead, pinned-axis callables); the record is consolidation_review/worldarray_trial.md in the working tree.

Full suite against the PyPI install: 1,141 passed, the one known import-hygiene failure. No live run on the cluster.

Live try on a workstation (no bsub, CPU)

cellmap_flow view on jrc_jurkat-1 with the models as local processes: mito_aff_unet_setup_16 served its layer end to end (resampled 13.76 → 16 nm read through worldarray, chunkmirage blocks, Neuroglancer). jrc_mus-livers_16nm_to_8nm_mito failed every chunk, and its warm-up, with "padded input size per channel: (1 x 1 x 1)": a bug from before this PR in the cellmap model type, which read the metadata's training input_shape (216³) while cellmap_models traces the TorchScript at inference_input_shape (288³), the only shape a traced UNet runs at. Fixed here (the inference shapes when present, with a test); the two liver models in models.yaml are the ones affected.

https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW

The Models tab's ✕ (services.launch.remove_model) stopped the model and
took its layer, but its config stayed among the session's models, so the
YAML Export Config wrote started it again with `cellmap_flow yaml`. The
config now goes with it (drop_config, which Add a model and Use this
model share for replacing a config by name); a model unticked on Submit
keeps its config, for its row on the Models tab and the pipeline builder.

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
web/src/backend/routes/rank.ts read every served model's raw output as
logits, as the catalog's models output them; the server's twin
(rank_routes._score) reads the output as a probability by the model's
output class, clipped to [1e-6, 1 - 1e-6], then its logit. A model whose
manifest says it outputs probabilities had 0.1 read as "object", so it
scored half at 0 and its cut-off, the seed threshold Use this model
sets, landed between its two values. The route now converts with
label-it's probability() and the served model's output_class, as the
server does; a logit model's score is unchanged within float32. The test
fakes' served ModelInfo carries the manifest's output class, as the real
one does (shared/model-info.ts).

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
… URL

Only Submit's redraw (PUT /api/pipeline) built a layer's URL blob with
dashboard_url and digest; startup, Submit's launches, Add a model, Use
this model and a finetune's layer built it from the live steps alone. So
an inference server running a merging postprocessor never posted its
equivalences for such a layer (chunkmirage_ops._send_equivalences returns
without a dashboard URL), and the first PUT of an unchanged chain redrew
every layer, since _shows() never matched.

One helper, Session.layer_blob(), builds the blob from
session.pipeline_spec with the dashboard's address and the digest, and
every path uses it. The address is the session's dashboard_url, which
dashboard.app.bind_dashboard sets when it binds the port; the CLIs'
startup binds before it builds the layers (create_and_run_app takes the
bound server), so the startup layers carry it too. It is the dashboard's
own address, by IP, which a server on the cluster can reach, rather than
the host the browser's request came by; a request's host_url is the
fallback when none is bound (tests).

The layer snapshot tests pin the digest, and the address at startup and
at a launch, that the layers now carry.

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
pyproject.toml pins chunkmirage at 7331a3fc (3 Oct) and
web/chunkmirage.lock at d6fd94ae (16 Oct), 70 commits apart. The test
reads both and asserts they agree, so they move together from now on.

It is xfail (strict) for now: at d6fd94ae chunkmirage moved Cast to
chunkmirage_contrib.ops (its commit d3acf43, a second package in the
same wheel), and serving/chunkmirage_ops.py imports it from
chunkmirage.ops, so bumping pyproject would break layer_ops. Every other
name cellmap-flow imports (ArrayInfo, Op, open_source, LRUCache,
Pipeline, select_axes, DatasetRegistry, create_app, MultiscaleSource,
TensorStoreSource, chunkmirage.serve) is still there with the same
signature, checked statically against the local checkout. Once the
import moves and pyproject is bumped, the marker comes off.

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
Four confirmed bugs from the dashboard review: the browser's rank read raw
output as logits; launched layers carried no dashboard_url or digest; Export
Config kept removed models; the two chunkmirage pins disagreed (test added,
xfail until the Cast import moves).

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
POST /api/finetune/submit read a field sent blank as 10 epochs, a batch
of 2, MSE, LoRA rank 8, class balance off and distillation left to the
trainer, while the Finetune tab shows 20, 8, margin, 64, on and 0.01: the
defaults in requests.py had drifted from the form's. They are the form's
now; the next commit derives the browser's own defaults from them and
adds the test that holds the form and the model together. The trainer's
own defaults are unchanged.

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
The dashboard's JavaScript speaks to the Flask routes in server mode and
to web/src/backend in browser mode, and nothing held the two to one
contract: the route lists, the request shapes and the shared numbers were
copied by hand, and a Flask route added without a browser twin became a
silent 503. cellmap_flow/schema.py is now the one place. It exports a
JSON Schema (draft 2020-12) of every request model, the table of every
route with where it is answered (both, stub, server or browser: the
method and path from the app's url_map, the availability declared beside
it, the two cross-checked) and the constants both modes share, and
`npm run gen` writes web/src/generated/cellmap-flow.schema.json,
cellmap-flow.ts (its types by json-schema-to-typescript 16.0.0, with the
constants, ROUTES and the chain steps' parameters as values) and
docs/api.md.

tests/web/test_schema.py fails when the generated files are stale, when a
Flask route is added without a declaration (naming it), when the
TypeScript router registers a route the table does not give the browser
or misses one it does, and when the server form's defaults leave
FinetuneSubmit's; web/test/unit/generated-routes.test.ts holds the router
to ROUTES, and CI's web job runs the --check, which needs json2ts. The
browser's DEFAULT_OPTIONS and LABEL_MAX_CHARS come from the generated
file, and contracts.ts re-exports the request body types.
web/ARCHITECTURE.md states the rule this serves, one implementation per
behaviour, with the table of what is both-mode, browser-only and
server-only.

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
…thon

web/build/export_data.py writes web/src/generated/python-data.json from
cellmap_flow, and the app imports it through python-data.ts in place of the
copies it kept by hand: the op registry with each op's parameters and
whether the browser runs it, the default chain, the organelle catalog, the
prompt's sentences and the AI-annotate limits and labels, the shaders, the
prediction colours, the layer keys tied to a source, the ranking's
constants, the finetune jobs' terminal statuses and log patterns, Label
it's undo depth and large-box size, and the unit table.

Each normalizer and postprocessor class says whether the browser runs it
(browser = True), and build/render.py offers only those on the browser
page, which listed FillHoles, Cellpose masks, ChannelSelector, Dilate and
EuclideanDistance and then refused them when ticked. A vitest test holds
the browser's ports equal to that list.

io/metadata.py's unit table gains the Greek mu and the British spellings,
which the browser already read and the server took for nanometres.

tests/web/test_python_data_fresh.py fails when the JSON is stale, and
replaces test_layer_carry.py's scrape of viewer/state.ts.

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
The dashboard's contract generated from the pydantic models
(cellmap_flow/schema.py → web/src/generated/cellmap-flow.{schema.json,ts},
docs/api.md), a route table with per-mode availability and the tests that
hold both backends to it, FinetuneSubmit's blank-field defaults aligned
with the form, and the one-implementation-per-behaviour rule in
web/ARCHITECTURE.md.

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW

# Conflicts:
#	CHANGELOG.md
The constants and catalogs the browser copied by hand from Python become
data generated from Python (web/build/export_data.py →
web/src/generated/python-data.{json,ts}): the op registry with each op's
browser flag, so the browser page offers only the steps it runs; the
organelle catalog, prompts and AI-annotate limits; viewer shaders and
colours; rank constants; job statuses; the default chain; the unit table,
which now reads the Greek mu and British spellings.

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW

# Conflicts:
#	CHANGELOG.md
#	web/package.json
They failed on any rewording or reordering and on nothing a user would
notice, and the owner asked for them to go.

- test_cli_surface: every command, flag and to_dict() of six CLIs. The
  model form's test moves to test_dashboard_routes; test_deployability
  keeps what Fileglancer's runnables.yaml runs; test_cli gains one test
  that an old console script says what replaces it.
- test_bsub_argv_snapshot: every bsub and local command line, frozen.
  test_launch_command's "reaches the server whole through LSF" covers it.
- test_served_metadata_snapshot: .zattrs, .zarray and chunk hashes. Its
  models and chains move to serving_helpers (SERVED_CASES), which the
  served-chunk tests in test_chunkmirage_server run.
- test_dataset_draws_snapshot: the RNG's draw sequence.
- test_training_snapshot: no per-platform loss table, weight hashes,
  checkpoint keys, stdout markers or directory layout; served == trained,
  merged ~ served and a real two-iteration CLI run stay.
- test_volume_snapshot: no .zgroup/.zattrs/.zarray or chunk-key pins.
- test_finetune_routes: no ANSWERS table of 60 exact error strings; each
  route's status instead.
- test_layer_sources_snapshot: loose checks of the layers each path puts
  in the viewer instead of their frozen JSON.
- test_blockwise_routes: the task YAML read back with yaml.safe_load
  instead of 12 byte-for-byte cases; config errors by status and field.
- test_render: no hand-written element-id lists, select options or help
  text; the page renders in both modes, and the Run tab's ids (read from
  panel.ts) are on the browser page and no other.
- test_prompts and ai-annotate-prompts: prompt wording (parity/ai-annotate
  still checks the two modes send the same prompts).

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
The same behaviour was often tested two or three times: a Python test, a
TypeScript unit test with the Python's numbers copied in by hand, and a
parity test against JSON the Python writes. The hand-copied layer cannot
notice when the Python changes; the parity tests can, and all stay.

- TypeScript unit tests of ops parity covers: args-codec (parity/args),
  infer-label, -morton, -norms (parity/label, morton, norms), infer-dtype
  (infer-stages' uint64 ids), infer-output (parity/label-it reads through
  boxFromChunks), infer-materialize (its test moves to run-region).
  infer-merger, -affinity and -post keep the one test parity can't make:
  the listener, the timing, the unsupported step.
- label-it.test.ts: backend-view-labels and parity/label-it.
- ai-annotate-images, -fill, -labels, -fake: the gemini "decodes by the
  bytes" and padding tests, the backend's accept/undo tests (which run the
  fake provider) and parity/ai-annotate.
- finetune-augment (its loss test moves to finetune-full), -distillation
  (finetune-run, finetune-head-trainer), -good-regions
  (backend-good-regions, finetune-run), -targets (parity/targets); and the
  opt-in live-Hub tests in finetune-full and -full3d.
- backend-router (backend-routes' 503 and 405), models-live (e2e smoke).
- test_model_geometry: every served and blockwise test does the arithmetic.
- test_globals: one test of the deprecated g in test_deprecated_imports.
- test_builder_page, test_dashboard_progress: the one id-lookup check is
  test_dashboard_tab_scripts (builder, Models, Finetune and Review tabs);
  progress polling is test_finetune_routes'.
- test_job_log, test_session_store: the job manager's listener tests and
  the routes' "submit after a dashboard restart".
- test_adaptation: test_lora_wrapper's adapter tests and the CLI restart
  runs; its import check is a finetune-run row in test_import_hygiene.
- test_staging: test_routes' malformed-id test sends ../../etc to accept,
  reject and resend (it gains the staging test's trailing-newline case).
- The Models-tab Submit fixture, copied between test_bioimage_zoo and
  test_cellpose_list, is kept once; test_paint_box uses test_view_labels'
  _Bucket instead of a third copy.

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
One behaviour was often tested at every layer it passes through: the
launch command at six (test_launch_command, the bsub snapshot, test_cli,
test_jobs_launch, test_dashboard_launch, test_deployability), finetune
submit at four (route, job manager command string, CLI, training run),
a restart in about eight places. Each keeps its outermost behavioural
test, and the rest of each file keeps one test per user-facing feature in
each mode:

- test_launch_command: three launchers (CLI infer, a YAML, the
  dashboard's Hugging Face launch) reaching the server whole through LSF
  and locally; test_jobs_launch keeps the start_hosts table.
- Finetune: the route submit test and one real CLI run; the job manager
  without its ~170 lines of trainer-flag substrings, its rehydrate table
  down to three states; restart through the route and one CLI resume,
  plus the restart token (test_restart_control) and Shift+R.
- Exact error and log text gives way to status codes, error categories
  and a keyword, in Python and TypeScript alike (backend-finetune's "in
  the Python route's words", backend-ai-annotate's 17-field status).
- The Hub's two full-3D training graphs (2,585 lines of JSON) and the 2D
  ones go: backend-finetune's 3D test builds a small graph of the same
  context in the test. tree.setup_54.json was referenced by nothing.

Kept on purpose: every AI-annotate secrets and egress test, the Lambda
whitelist, test_deployability whole, the parity tests and fixtures, and
the real failure modes (OOM retry, NaN batch, restart, resume after a
dashboard restart). A few tests that the trims left with no other test
come back: the deprecated /api/process and /api/pipeline/apply, the
uint16 volume for an instance model, and relabel_instances.

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
pytest-xdist joins the test extra, and pixi's test feature for the macOS
job; both pytest steps run with -n auto. The whole suite takes about 30 s
on 8 workers locally.

pixi.lock is refreshed with pixi lock: it adds pytest-xdist and execnet to
the test and dev environments and the extra to cellmap-flow's
requires_dist (pixi lock --check passes). Two packages' purl source tags
changed from hash-mapping to compressed-mapping on the way, which is
pixi's mapping service, not a version change.

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
architecture.rst no longer names characterization snapshots as the
refactor guards. Its Tests section states the rule the suite now follows:
one behavioural test per feature in each mode, at its outermost layer; no
wording or shape pins; parity where both modes must agree, and no
TypeScript unit test of hand-copied Python values for an op parity covers;
snapshots for a refactor written on its branch and dropped before merge.
The import-hygiene groups, the dashboard-JavaScript checks and the CI
command (-n auto) match the tests as they are. copilot-instructions and
web/ARCHITECTURE.md's test tree say the same.

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
The test suite cut from 46.6k to 27.1k lines: wording and shape pins,
duplicate layers and hand-copied TypeScript unit tests of parity-tested ops
are gone; one behavioural test per feature per mode, every secrets and
parity test, and the real failure modes stay. CI runs pytest with xdist.

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW

# Conflicts:
#	tests/finetune/test_finetune_routes.py
#	tests/utils/test_dashboard_launch.py
#	tests/utils/test_layer_sources_snapshot.py
#	tests/web/test_render.py
pyproject.toml and web/chunkmirage.lock pointed at two older commits
(7331a3fc and d6fd94ae), and tests/web/test_pins.py said so with a strict
xfail: at d6fd94ae chunkmirage moved Cast to chunkmirage_contrib.ops, which
serving/chunkmirage_ops.py still imported from chunkmirage.ops. Both pins
are now chunkmirage's main of 2026-10-21 (Pipeline.plan, materialize over
a box, the core/contrib split), the import follows Cast, and the xfail goes.
Every other chunkmirage name cellmap-flow imports (ArrayInfo, Op, LRUCache,
Pipeline, select_axes, DatasetRegistry, create_app, MultiscaleSource,
TensorStoreSource, open_source) is still there with the same signature; the
serving tests pass with 9450e200 shadowing the environment's chunkmirage.

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
daisy 2.0.0 came out today as a rewrite: no daisy.tcp, daisy.Server or
daisy.Client, which blockwise_processor uses, so CI's pip install broke on
main and here. pixi.lock refreshed for the pin (one line).

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
cellmap_flow/portable is the Python both modes run: importable with the
standard library, numpy, scipy, pydantic and Pillow alone, so the server
runs it in its process and the browser can run the same files in
chunkmirage's Pyodide workers. tests/portable/test_boundary.py imports
every module under it in a fresh interpreter and fails on anything beyond
that line, as chunkmirage's test_core_boundary.py does for its core.

finetune/rank.py moves there whole (RANK_BLOCKS, RANK_SMOOTHING, CUTOFFS,
NARROW_WINDOW, WINDOW_MARGIN, spread, empty_tallies, paint_edges, tally,
rank_score, windowed; only its docstring's first lines change), and
finetune/rank.py re-exports it so every import site still works;
rank_routes.py, schema.py and web/build/export_data.py take the constants
from portable.rank. The maths of carry_paint (the axis sources, the
first-visit mask and the voting loop) is portable/carry.py, on a Grid of
corner, voxel size, shape and chunks and a read(lo, hi) callable, and
returns the new chunks that got paint, whole, with the counts;
session/volume.py's carry_paint keeps the zarr reads and writes around
it. portable/calls.py is what the browser will call: each function decodes
its arrays, calls the maths and encodes the answer.

Found on the way: capping carried labels at the new dtype's maximum with
np.minimum(found, 65535) fails under numpy 2 when the old labels are
uint8, so Use this model could not carry a uint8 volume's paint onto a
uint16 one (an affinity model's) on the server; the cap now applies only
where the old dtype can exceed the new one. tests/finetune/test_rank.py
passes unchanged.

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
…rker

The browser's Which model to start from no longer has a TypeScript port
of the score: web/build/portable.ts bundles cellmap_flow/portable/**/*.py
as text (import.meta.glob, as chunkmirage bundles its own ops), and
infer/engine.ts hands the files to Engine.start as `modules` when a
dataset opens, with python: "always" and one worker, so Pyodide and numpy
load in the background while the data opens; the log says when they are
in. Inference.python(fn, args, arrays) is chunkmirage's Engine.call by
module path (its PR #46, JaneliaSciComp/chunkmirage, pending; the lock
stays at 9450e200 and the build uses a local clone meanwhile).

backend/routes/rank.ts keeps its reading of the painted chunks and of the
model's output (the output_class to logit conversion included) and hands
each chunk's labels, targets and logits to portable.calls:rank_tally, the
tallies travelling in the call's JSON since the workers take turns, then
asks rank_score and windowed; Use this model sends the old volume's
painted chunks whole to portable.calls:carry_paint and writes the new
chunks it answers. finetune/rank.ts (239 lines) and carry.ts (144 lines)
are deleted with their unit tests; the route-level tests stay and run on
the same Python in node (test/unit/pyodide.ts: the pyodide package at
0.28.3, the version chunkmirage's workers load, numpy's wheel from
jsDelivr the first time, the tests skipping without the network). The
RANK_BLOCKS and WINDOW_MARGIN the route reads come from the generated
contract, as before.

Measured on the built app in headless Chromium on this workstation:
Python ready 3.2 s after the dataset opens (3.7 to 4.3 s after page load,
10 MB over the wire), the first rank_tally 0.05 s, warm 0.02 s, a
32-chunk scoring loop 0.4 s.

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
Every description of a model is a model entry, {type, name, env,
**arguments} (models/entry.py), and registry.build_model is the one place
a config is built from one: a YAML's models:, infer's options, the model
form, the Models tab's catalog, Hugging Face, zoo and Cellpose picks, Add a
model and serve --model. The entry's values become constructor arguments by
the constructor's annotations (entry.coerce), replacing coerce_cli_args,
coerce_form_params, build_model's voxel shorthand and the constructors'
own parsers (_as_int_tuple, _voxel_size, _sizes, _names, _voxel_sizes,
resolve._voxel_size, _parse_comma_separated_values). The two type tables
are one lookup that takes a type name or a class name; the form posts
type, class_name deprecated for one release.

serving.launch.launch starts every server (infer, yaml, add --run, the
Models tab, the Training tab's ranking): resolve_data_path(scale), the
launcher-settings fallback, start_hosts. The dashboard's five run_* and
four launch loops are one path over built configs. Fixes the Models tab
ignoring a config's scale.

One bioimage entry builder and one voxel-size rule (the config's: given,
else declared, else trained at); configs/bio and bioimage_catalog read
io.metadata's unit table, so the picker accepts pm, cm and m.

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
finetune/options.py holds every setting a finetune trains with and its
default, in one pydantic model: the mode (lora, full, or the browser's
head, in place of "a LoRA rank of 0 means full"), rank and alpha, epochs,
batch, learning rate, loss, margin, smoothing, class balance, distillation
weight and scope, augmentation, mixed precision, the target type, channel
and offsets, the output class, and the manifest's patches per epoch and
rehearsal fraction. for_session() is what submit adjusts for a painted
session or a distance or flow target (common.training_settings' rules).

FinetuneSubmit and FinetuneRestart derive from it: a blank form field
takes the field's default, finetune_mode is mode, and output_type left
blank is detected from the model; options() gives the TrainingOptions a
body asks for, changes() what a restart gave. schema.py reads
FINETUNE_DEFAULTS from the options model, so the browser's generated
defaults are the same values; the generated schema and TypeScript now
carry each setting's type and description.

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
The settings were listed seven times, form to trainer, and their defaults
drifted. Now the submit route builds one TrainingOptions from the body
(with the detected target, offsets and the served model's output class),
adjusts it for the session and hands it to submit_finetuning_job, which
passes it to build_command and the run's metadata. The CLI makes a flag
of every field (cli.add_training_flags; the old flag names are kept, and
--distillation-all-voxels still means --distillation-scope all), reads
them back with options_of, and training_flags is the inverse the job
manager writes the command with. RESTARTABLE_ARGS is the model's fields
less the mode, the output class and the manifest's, plus the three names
a job started before this version knows; each restart value is checked
as the form's are, and a rank for a full finetune, or 0 for a LoRA job,
is refused there instead of being reconciled by the session loop. The
CLI's own defaults become the form's.

lora_trainer.py (1,516 lines) becomes finetune/trainer/: loop.py
(Finetuner, with LoRAFinetuner kept as its old name: the epoch loop, the
best epoch, the stop signal), supervised.py (the 7-way loss construction
and the 5-way dispatch as one SupervisedLoss), distillation.py (the
lambda and scope policy, the teacher), recovery.py (mixed precision and
the fall back to fp32, OOM mitigation, the finite-gradient check),
diagnostics.py (the single-class warning, the gradient-flow watch) and
tensorboard.py (the writer, config card, image strip, scalars). The
checkpoint files are written and read by adaptation.py's strategies.

The trainer no longer probes for a built-in sigmoid itself (the probe and
its four cache helpers are gone). It takes output_class, serving.probe's
unit / signed_unit / unbounded: the submit route reads it off the running
model's model_info, the job takes --output-class, and when neither knows,
the job probes the model once with the same classifier
(supervised.probe_output_class). losses.as_probabilities and as_logits
read by that class, so a tanh's [-1, 1] is (p + 1) / 2 in training as it
is in ranking and seeding, where the trainer took it for logits.

Tests: one for the single definition (a blank form, the bare command line
and the trainer's defaults are one TrainingOptions, and the command line
says every setting and parses back), one for the tanh model read as its
server reads it; the existing ones adapt to the trainer's new home, the
stop-signal test to there being no output probe before epoch 1, and the
bounded net's snapshot cases declare output_class unit.

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
With the other wrappers that make a network trainable; lora_wrapper
imports them from there, so its callers are unchanged. The adapter's
names depend on the module tree, BatchLoopWrapper's model. prefix
included, and neither wrapper changes. With the CHANGELOG entry for the
training settings and the trainer split.

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
One TrainingOptions for every finetune setting, read by the submit and
restart bodies, the job command line, the run metadata, the trainer and
the generated browser defaults; the trainer reads a model's output as its
server does; lora_trainer.py (1,516 lines) becomes finetune/trainer/.

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
models-tab.js (1,112 lines, one closure) becomes dashboard/models/: index
(the wiring and Submit), loaded, catalog, huggingface, zoo, cellpose,
add-model, job-logs and server-config, with row.js for what the rows
share. Each list exports payload(), its part of POST /api/models, and
untick(model), so Submit is the merge of the payloads and a ✕ tells each
list; rows are built with lib/dom.js h(). Behaviour as before.

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
One model entry, one constructor path (registry.build_model) and one
launcher (serving.launch.launch) in place of nine entry points, three
coercers, two type tables and eight launch sites; the Models tab's JS is
one module per list.

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW

# Conflicts:
#	CHANGELOG.md
Both pins move from 9450e200 to 7307e36b (JaneliaSciComp/chunkmirage#46),
so the web build no longer needs a local clone of the branch; pixi.lock
refreshed for the pin.

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
The paint logic written once, in Python (cellmap_flow/portable/), run by
the browser through chunkmirage's Pyodide workers; rank.ts and carry.ts
deleted. Both chunkmirage pins at 7307e36b, the commit that merged the
consumer-python hook (JaneliaSciComp/chunkmirage#46).

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW

# Conflicts:
#	CHANGELOG.md
#	cellmap_flow/schema.py
…/ over it

Trial of worldarray (868b2aa) as the base of the reader. worldarray opens
the level, parses its metadata, reads index boxes padded and resamples;
cellmap-flow keeps its corner-based ArrayMeta (built from worldarray's),
the s<N> precomputed names, the corner Grid reads are mapped by, the
relabel rule (corner rescaled), the input chain applied before padding,
and the deprecated arguments.

io/metadata's parsers, io/source's opener and padded read and
io/resample's kernels are gone; io/store is worldarray.store under
cellmap-flow's signatures; io/multiscale is worldarray's select_level.

The ImageDataInterface's own grid is read_grid now: worldarray owns
_grid and grid.

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
The nm-per-unit table is worldarray's length table now: the same values,
in its order, with its two extra spellings of the angstrom (ångström, Å).

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
worldarray now opens an array path in place, resamples with the target
shape reported, applies a transform before padding, names precomputed
scales s<N>, chooses levels from the group document, lists stored chunks
and exports its unit table. So the reader holds a WorldArray instead of
subclassing one: _grid, shape, voxel_size, padding and ts keep their
meanings with nothing shadowed, and the read_grid rename is undone.

Gone: Resampling, the chain-then-pad clipping for stored reads (the chain
is worldarray's transform), the precomputed and group level shims,
list_populated_chunks' directory walk, io/store's access wrappers.
Kept: the relabel rule (corner rescaled, so voxel i stays voxel i;
worldarray keeps the corner in nm), the chain after resampling (worldarray's
transform runs before it), cellmap-flow's ArrayMeta and Grid.

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
…g, multiscales first

worldarray's spec (Conventions 1 and 4) decides three placements the
parity table pinned the old way:

- a COSEM transform's translate is voxel 0's centre (n5-transform,
  v3-transform-attrs, n5-offset-from-multiscales);
- stored offsets are never rounded onto the grid (funlib-offset-off-grid,
  n5-pixel-resolution, the public COSEM N5 level, which now sits at the
  zarr copy's corner);
- a group's multiscales transform outranks the array's resolution
  (n5-offset-from-multiscales).

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
WorldArray.list_chunks opens the array (metadata probes and a tensorstore
open) before listing its keys; the overlay, sampler, manifest and volume
callers want only the chunk file names of a local annotation volume, and
the overlay tests' volumes are chunk files without a .zarray.

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
…s and chunk keys

worldarray now relabels voxel for voxel (the corner scales with the
voxel size), applies a post_transform after resampling and the cast back
to the stored dtype, guards GCE's metadata server, caps only decoding and
file I/O with capped_context(network=False), and lists chunk keys with no
metadata document. So the reader takes its grid from worldarray's voxel
size and corner, reads a resampled level through post_transform (one
channel pinned on a box over every axis), and io/ drops store.prepare,
reader_context and the directory walk.

list_levels opens each level on a context of its own: an ArrayMeta needs
the level's shape, dtype and chunks, which Pyramid.placements does not
give, and a shared context revalidated a cached precomputed info.

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
…ws BigDataViewer

worldarray's spec (Conventions 1) places an N5 level with downsamplingFactors
and no offset by the BigDataViewer rule, each voxel centred on the
full-resolution voxels it covers: corner (-4, -2, -1), not (0, 0, 0).

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
worldarray 0.1.0 (cf2353a) gives every level's ArrayMeta from the group's
documents (Pyramid.metas) and pins non-spatial axes on read(select=). So
list_levels opens no level and needs no context of its own, and a
resampled multichannel read takes select= instead of a box over every
axis. The (post_)transform still gets the pinned axis, of size 1, so the
chain is handed the one channel without it, on stored reads too.

read_array_meta stays on the opened array: metadata(open=False) on a group
with no levels raises IndexError, not the "no levels found" ValueError.

worldarray.metadata, the module, is worldarray.placement now; select_level
is taken from the top level.

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
….0 drops it

worldarray 5e9d17d (tag v0.1.0) squeezes the axes a select= pinned before
calling transform / post_transform, so the wrapper that took the channel
out and put the singleton axis back is no longer needed.

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
`worldarray>=0.1,<1` in the core dependencies, pixi.lock refreshed (every
pypi environment gains it), and the changelog entry: what the reader hands
to worldarray, what stays in cellmap_flow/io, and the conventions that move
some volumes by up to half a voxel.

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
cellmap_models traces the export at the metadata's inference_input_shape
(torch.jit.trace), and a traced UNet runs only at that shape. The model
type read the training input_shape, so jrc_mus-livers_16nm_to_8nm_mito
(216³ in training, 288³ exported) failed every chunk, and its warm-up,
with "padded input size per channel: (1 x 1 x 1)". The inference shapes
are used when the metadata has them, the training shapes otherwise.

Claude-Session: https://claude.ai/code/session_01EzWMS4pG3tm1ZtvKULfSfW
@davidackerman
davidackerman marked this pull request as ready for review October 22, 2026 00:45
@davidackerman
davidackerman merged commit e79f709 into main Oct 22, 2026
7 checks passed
@davidackerman
davidackerman deleted the consolidate branch October 22, 2026 00:45
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.

1 participant