## [1.6.450] - 2026-07-30
### Fixed — **SpIDA recovers low-density N — the N6-1 truncation bias is fixed (the last xfail closed).**
N6-1's deferred recovery, resolved. `build_intensity_histogram` used to drop every pixel at/below the noise floor
(`p = p[p > 0]`), discarding the zero-molecule (k=0) population — a large fraction at low density (P(k=0)=e⁻ᴺ) —
which biased the fitted density upward (~+56% at a true N=2). The fix has two parts, both grounded in SpIDA being
a distribution-MOMENT analysis:

- **Keep the k=0 population** (`p >= 0`, dropping only genuinely sub-floor negatives), so the histogram and the
  moments read from it reflect the FULL pixel population and are unbiased at every density.
- **Anchor on the moment estimate where the least-squares fit collapses.** The curve fit can over-fit the sharp
  zero-intensity bin at low density and *under*-estimate N (a naive "keep the zeros and re-fit" was verified to
  over-correct to ~0.6); so when the fit falls below half the moment anchor, `fit_spida_histogram` reports the
  moment estimate instead. The fit still governs the peak-brightness shape where it is well-behaved.

Result: SpIDA now recovers N across the range — true N = 2 reads ≈ 2.0 (was ~3.1), true N = 8 unchanged (~7.8) —
and the pedestal sensitivity that guards against a camera offset is preserved (a 200-count pedestal still inflates
a true 8 to ~31). The `low_density_regime` flag (1.6.436) survives but changes meaning: a low N is no longer
biased, only inherently higher-variance (fewer samples per molecule count), so it is flagged as a caution to
confirm against a monomeric control, not a number to distrust.

Tests (`tests/test_group_a_moments.py`): the N6-1 strict-`xfail` golden-master is promoted to passing; a new
recovery guard pins N tracking the truth across densities 2→8; the flag test is updated to the new (variance, not
bias) meaning; the N=8 baseline and pedestal tests stay green. **Zero xfails remain in the suite.** Full gate green.

## [1.6.449] - 2026-07-30
### Added — **Execution kernel, increment B: a torch-free segmenter (local threshold) — all op archetypes now proven (Method-Widget Spec 6).**
Rounds out increment B by diversifying beyond filters: `local_threshold` (Niblack/Sauvola local thresholding) is
a TORCH-FREE segmenter, so its route-equivalence workflow proves the segmentation kernel path in the CORE gate —
where the torch-gated cellpose family cannot run. Its binary mask round-trips exactly as float32 0/1.

With this the kernel spans **every operation archetype**: enhance (background removal + 5 filters), measure (MSD,
clean-detect, partition enrichment, the three coloc coefficients), segmentation both torch-gated (cellpose) and
torch-free (local threshold), and the composite (coloc) and looped (time-series) workflow shapes. 12 route-
equivalence workflows, **14 ops migrated** to `OperationService.execute`, every one asserted `≈ kernel`. Full
gate green.

## [1.6.448] - 2026-07-30
### Added — **Execution kernel, increment B (cont.): three more filter families — bilateral, LoG, FFT-bandpass (Method-Widget Spec 6).**
Continues increment B with three more pure array→array filter op families: `bilateral` (edge-preserving),
`log` (Laplacian-of-Gaussian), and `bandpass` (FFT bandpass). Each gets a route-equivalence workflow proving
`headless ≈ session ≈ kernel` bit-for-bit and a kernel registration; the near-identical filter rows are built by
a small `_filter_only_workflow` factory to keep the fixtures DRY. Batch is a documented gap for each (a standalone
filter is not a recorded per-image batch step).

The route-equivalence matrix is now **eleven workflows**; the kernel covers **13 ops** — the six increment-A
families plus five image filters (`gaussian`, `dog`, `bilateral`, `log`, `bandpass`). Full gate green.

## [1.6.447] - 2026-07-30
### Added — **Execution kernel, increment B: image-filter families (Gaussian, DoG) migrated with NEW route-equivalence workflows (Method-Widget Spec 6).**
Increment A migrated the kernel across the existing route-equivalence matrix; increment B extends the matrix
itself. Two pure image-filter op families — `gaussian` (Gaussian smoothing) and `dog` (difference-of-Gaussians
blob enhancement) — each get a NEW route-equivalence workflow (Workflows 7 & 8) proving
`headless ≈ session ≈ kernel` bit-for-bit on a deterministic array→array filter, plus their kernel registrations.
Both are enhance ops (the filtered image is the `artifacts` payload); batch is a documented gap for each (a
standalone filter has no batch replay step — it is an interactive/kernel operation, not a recorded per-image
step).

The matrix is now **eight workflows**. Migrated ops: `rolling_ball`, `condensate_physics.compute_msd`, `clean`,
`cellpose`, `client_enrichment`, the three coloc coefficients, plus `gaussian` and `dog`. Each new op family is
one `Workflow(...)` entry + one kernel — the pattern the spec prescribes for growing coverage. Full gate green.

## [1.6.446] - 2026-07-30
### Added — **Execution kernel, families 5 & 6: partition enrichment + colocalization — every canonical workflow now proves ≈ kernel (Method-Widget Spec 6).**
Closes the last two `kernel` gaps in the route-equivalence matrix, so all six canonical workflows now assert the
kernel computes identically to their other routes:

- **Family 5 — `client_enrichment`** (partition/enrichment, a MEASURE op returning a one-row metrics table). A
  per-frame series is a WORKFLOW over one op: the kernel runs the op, Workflow 6's `kernel` route loops it per
  frame — same science as `headless`, so the time-series-partition row closes `≈ kernel`.
- **Family 6 — colocalization** (a COMPOSITE of three single-coefficient ops: `coloc.manders_m1`,
  `coloc.manders_m2`, pixel-wise `colocalization`/Pearson). Each is its own kernel; Workflow 5's `kernel` route
  calls all three and assembles the same 3-row table — the kernel handling a multi-op workflow (the workflow
  orchestrates, each kernel runs one op).

With this, **zero kernel gaps remain** in the route-equivalence matrix: rolling-ball, puncta, VPT→MSD, cellpose,
time-series partition, and colocalization all prove `headless ≈ (batch) ≈ session ≈ kernel`. Eight ops are now
migrated to `OperationService.execute`. (Coloc's BATCH route stays a documented gap — it has no batch replay
step — but its kernel route now runs.) Full gate green.

## [1.6.445] - 2026-07-30
### Fixed — **Route-equivalence now genuinely runs the `kernel` route; cellpose migrated (family 4) (Method-Widget Spec 6).**
**Correction:** families 1–3 (1.6.442–444) added a `kernel` route to the route-equivalence workflows, but the
harness's `run_all_routes` only iterated `ROUTE_ORDER = (headless, batch, session)` — so those `kernel` routes
were in the workflow dicts but **never actually run or compared**. The kernel's science was still verified
(`test_operation_service.py` asserts the kernel output equals a direct toolbox call bit-for-bit), but the
"`≈ kernel` row closes" claim was not yet backed by the matrix. This adds `kernel` to `ROUTE_ORDER`, so the
harness now genuinely executes each migrated workflow's kernel route and asserts it agrees with `headless`, and a
workflow whose op is NOT migrated declares `kernel` a documented gap (coloc, time-series partition) — an
unmigrated op is a visible gap, never a silent absence. The `runs_all_routes` count tests now expect `kernel`.

**Family 4:** `cellpose` cell segmentation is migrated (a CREATE op → the label mask in `artifacts`).
`OperationService.execute("cellpose", {"image": <normalised>}, {"cell_diameter", "postprocess"})` runs the same
`cellpose_segmentation` science as the manual/batch/session routes; Workflow 4 now proves `headless ≈ batch ≈
session ≈ kernel` (torch-gated). Migrated ops: `rolling_ball`, `condensate_physics.compute_msd`, `clean`,
`cellpose` — the four canonical workflows that have a kernel route, all genuinely compared. Full gate green.

## [1.6.444] - 2026-07-30
### Added — **Execution kernel, family 3: clean spot detection migrated — all torch-free canonical workflows now prove ≈ kernel (Method-Widget Spec 6).**
Family 3 is `clean` (`clean_detect`) — clean-mask spot detection with per-object measurement, another MEASURE op.
`OperationService.execute("clean", {"image": …}, {psf_sigma, psf_size})` returns
`AnalysisResult(entity_type="puncta", measurements=<DataFrame>)`, the same `clean_detect` science the manual and
session routes run. Workflow 2 (puncta) gains a `kernel` route and closes its `≈ kernel` row.

With this, **all three torch-free canonical route-equivalence workflows** — background removal (Workflow 1), puncta
detection (Workflow 2), and VPT→MSD (Workflow 3) — now assert `headless ≈ (batch) ≈ session ≈ kernel`, so the
migrated science is proven identical across every route including the new kernel. Migrated ops: `rolling_ball`,
`condensate_physics.compute_msd`, `clean`. (Cellpose/Workflow 4 is torch-gated; its kernel migration waits on the
torch lane.) Full gate green.

## [1.6.443] - 2026-07-30
### Added — **Execution kernel, family 2: MSD transport analysis migrated (Method-Widget Spec 6, the measurements path).**
Continues the per-family kernel migration. Family 2 is `condensate_physics.compute_msd` — a MEASURE op, so it
exercises the other `AnalysisResult` path: the result is the measurements TABLE (ensemble MSD per lag), where
family 1 (background removal) produced only an artifact array. `OperationService.execute(
"condensate_physics.compute_msd", {"tracks": …}, {frame_interval_s, min_track_length, …})` returns
`AnalysisResult(entity_type="track", measurements=<DataFrame>)`, a thin wrapper over the same `compute_msd`
science the manual/session routes run.

Route-equivalence: Workflow 3 (VPT → MSD) gains a `kernel` route asserted to agree with `headless ≈ session` on
the MSD table — its `≈ kernel` row now closes. Tests (`tests/test_operation_service.py`, +1): a measure op returns
its table in `measurements` (not `artifacts`), and `migrated_ops` now reports both families. Full gate green.

## [1.6.442] - 2026-07-30
### Added — **The execution kernel: OperationService.execute → AnalysisResult, first family migrated (Method-Widget Spec 6, proof).**
The convergence the audit and the roadmap call for: ONE place an operation's science runs, below batch /
Navigator / generated panels / manual panels / headless — so the same step cannot compute differently depending
on how it was launched. `pycat.kernel.OperationService.execute(op_id, inputs, params)` runs a registered
operation and returns a typed `AnalysisResult` (measurements for a measure op; the produced array in `artifacts`
for a create/enhance op). An op with no kernel yet raises a clear error — it still runs through its batch/panel
route until migrated; nothing is silently rerouted.

Migrated **per operation family, behind route-equivalence** — not a big-bang refactor. This lands the FIRST
family, background removal (rolling-ball), and extends the cross-route matrix: Workflow 1 in
`test_route_equivalence.py` now has a fourth route, `kernel`, asserted **bit-for-bit identical** to
`headless ≈ batch ≈ session` (the same `rb_gaussian_bg_removal_with_edge_enhancement` call on the same raw
image). The kernel is a THIN wrapper over the existing toolbox science, not a reimplementation.

Tests: `tests/test_operation_service.py` (`base`, 4) pins the kernel contract (typed `AnalysisResult`; same
science as the direct toolbox call; an unmigrated op raises rather than reroutes; `migrated_ops` reports
coverage); the route-equivalence row proves the science agrees across all four routes. This is a proof-of-concept
first family — subsequent families each register a kernel and close their own `≈ kernel` row. Full gate green.

## [1.6.441] - 2026-07-30
### Added — **The planner explains its DEPENDENCY choice too: "why this segmenter, not the others" (Method-Widget Spec 5 core, extended).**
Extends the terminal explainer (1.6.440) to the dependency layer. `Planner.explain_provider_choice(goal, ctx)`
returns, for a resolution goal (a Capability — e.g. the instance-labels a segmenter must provide), the candidate
providers with their CONTEXT SCORE (+1 context-matched specialist / 0 general / −1 unconfirmed specialist / −2
context-violated) and preference, and which `_pick` selects; `explain_segmentation_choice(intent, ctx)` is the
convenience that builds the segmentation goal for the target. Reuses `_pick` for the winner and the shared
`_context_score` for the scores — no drift.

It surfaces the dependency-layer anti-black-box insight: on **brightfield**, `bf_segment` (a context-matched
specialist, score +1) wins the condensate segmenter over the generic `subcellular_segment` **even though the
generic has higher preference** — the context score is the deciding factor, now legible. On neutral 2D
fluorescence the same call shows `cellpose` chosen by preference among a full field of alternatives.

Tests (`tests/navigator/test_navigator_selection_explain.py`, `base`, +4): preference-wins-when-neutral;
specialist-beats-higher-preference-generic (bf_segment +1 vs subcellular_segment 0 with higher preference); the
explained segmenter matches the compiled plan (no-drift guard); and no-target → None. Full gate green.

## [1.6.440] - 2026-07-29
### Added — **The planner explains its terminal choice: "why this op, not that" (Method-Widget Spec 5 core).**
The anti-black-box payoff, from the planner's OWN logic (no authored content needed): `Planner.explain_terminal_choice(intent, ctx)`
returns, per requested observable, the terminal ops the planner CONSIDERED — each with its selection scores
(`in_vitro` context bonus, target specificity, preference) and whether it was chosen — ordered winner-first. So a
scientist can see not just *what* was selected but *why*, and what the runner-up was. On a bead/viscosity intent
it shows `vpt.microrheology` chosen over the generic `condensate_physics.fit_anomalous_diffusion` **despite the
generic's higher preference**, because it is specialised to the bead target — the scores make that legible.

Built by extracting `_pick_terminal`'s scoring (the `in_vitro` context bonus + target specificity) into shared
methods (`_terminal_ctx_bonus`, `_terminal_specificity`) that BOTH the picker and the explainer call, and taking
the winner straight from `_pick_terminal` — so the explanation **cannot drift** from the actual pick. The
extraction is behaviour-preserving: all navigator + route-equivalence tests pass unchanged.

Tests (`tests/navigator/test_navigator_selection_explain.py`, `base`, 4): the explanation names the considered
terminals and the winner (winner-first); it exposes WHY the winner won (specificity 1 vs 0 where preference alone
would have picked the generic); the explained `chosen` matches the compiled plan exactly (the no-drift guard);
and an observable with no terminal is simply absent, never a fabricated entry. Full gate green.

## [1.6.439] - 2026-07-29
### Added — **Operation-guidance infrastructure: schema + reader + coverage ratchet + authoring workbook (Method-Widget Spec 3, infra only).**
Spec 3 lets the Navigator not just choose an op but *explain* one — when to use it, its advantages, limitations,
alternatives, when it does not apply, and references. That content is a scientific judgement authored by hand and
must never be machine-generated, so this ships the INFRASTRUCTURE with the content store deliberately EMPTY:

- **`navigator/data/operation_guidance.json`** (schema-versioned, `guidance: {}`) — the store, keyed by op-id
  like `section_bindings.json`. Each entry (once authored) has `when_to_use` (prose) + `advantages` /
  `limitations` / `alternatives` / `not_applicable_when` / `references` (lists).
- **`navigator/guidance.py`** — the reader: `guidance_for(op_id)` returns the authored entry or `None` for an
  unauthored op (refuse-to-guess, the same discipline as the adapters and section bindings), `authored_op_ids()`
  for the ratchet, and the authoring vehicle — `generate_guidance_workbook(path)` writes a fill-in `.xlsx` (one
  row per catalog op, factual columns pre-filled, judgement columns blank) and `ingest_guidance_workbook(path)`
  reads a filled workbook back into the JSON (list fields split one-per-line; blank rows are not entries). The
  runtime reader needs only `json`; the workbook helpers import `openpyxl` lazily.
- **`tests/navigator/test_guidance_coverage.py`** (`base`, 5) — the ratchet: the store is well-formed and
  schema-versioned; every authored entry references a REAL op (catalog + measure ops) with only known fields of
  the right types; the reader refuses to guess; authored coverage only grows (floor 0 while empty); and the
  workbook round-trips (generate → author one op → ingest yields it, ingesting to a tmp path so the shipped store
  is never mutated).

This is the schema + reader + ratchet + authoring vehicle the Spec-3 curation project needs; the guidance CONTENT
is the scientist's to author (and gates Specs 4–5). Full gate green.

## [1.6.438] - 2026-07-29
### Added — **Custom Methods submenu — saved methods rebuild into a generated panel (Method-Widget Spec 2, complete).**
Completes Spec 2: the persisted methods (1.6.437) now surface in the analysis tree and rebuild on demand. Two
parts:

- **`plan_from_saved_method(template, central_manager)`** (`navigator/session.py`, headlessly tested) — the
  rebuild step: recompiles a saved method's ANSWERS against the CURRENT data's context (`context_from_session`)
  into a fresh plan, so every quality gate re-evaluates on the live data (verdicts were never stored). Pure — no
  Qt — so the menu's core logic is verified apart from the widget it feeds; a test drives it with a fake
  central_manager and confirms a saved viscosity method recompiles to a runnable plan end to end.
- **The Custom Methods submenu** (`ui/custom_methods_menu.py` + a one-line hook in `menu_manager`) — the analysis
  tree's first DYNAMICALLY populated submenu: it lists the saved methods (repopulated on `aboutToShow`, so
  saving/deleting is reflected without a restart) and, on selection, recompiles via `plan_from_saved_method` and
  docks a `GeneratedMethodUI`. Deliberately kept OUT of `menu_manager` (a complexity-ratchet concentration point)
  so the feature does not grow that god-file — the hook is a single line, and a 2-line comment there was tightened
  to hold the file at its 1105-line ceiling.

The submenu is GUI-bound (Qt menu wiring) and shares the generated panel's Spec 1.6 manual acceptance; its rebuild
logic (`plan_from_saved_method`) is fully headlessly tested. Full gate green.

## [1.6.437] - 2026-07-29
### Added — **Generated-method persistence: schema versioning + duplicate (Method-Widget Spec 2 core).**
Spec 2 persists a generated method so it survives sessions and reappears as a Custom Method. The store already
existed (`navigator/templates.py` — `GuidedTemplate` saves the answers, the section/step list, and the reviewed
parameters via `save_template`/`load_template`/`list_templates`/`delete_template`/`rename_template`); this adds
the two pieces Spec 2 requires on top of it, both headlessly verified:

- **Schema versioning** — `GuidedTemplate` now carries `schema_version` (current `_SCHEMA_VERSION = 1`), written
  into every serialized entry, so a future field change can migrate old saves rather than silently mis-read them.
  Backward-compatible: an entry written before versioning (no key) reads as the current schema (the fields are
  unchanged — only the marker was added), not as corrupt.
- **Duplicate** — `duplicate_template(name, new_name)` copies a saved method while KEEPING the original (unlike
  rename), refusing to overwrite an existing name, copy a missing source, or take a blank name — completing the
  Custom Methods CRUD set (delete/rename already existed).

Tests (`tests/navigator/test_navigator_templates.py`, `base`, +4): schema_version round-trips; a pre-versioning
entry still loads; duplicate keeps the original and makes an independent copy carrying the same answers +
parameters; and duplicate refuses to overwrite / copy-nothing / take a blank name. The **Custom Methods submenu**
(the GUI layer — a dynamically-populated `menu_manager` submenu that rebuilds a saved method into a generated
panel) builds on this and is the next, GUI-bound step. Full gate green.

## [1.6.436] - 2026-07-29
### Added — **SpIDA flags the low-density regime where the histogram truncation biases N (N6-1 guardrail).**
N6-1 confirmed `build_intensity_histogram` drops every pixel at/below the noise floor (`p = p[p > 0]`), which at
low density removes the many zero-molecule pixels and inflates the fitted N (~+56% at a true N=2). Attempting the
naive fix REFUTED it: under the repo's doubly-Poisson model, keeping the zeros over-corrects a true N=2 to ~0.6 —
worse, in the opposite direction — so no simple data cut recovers truth; a truncation-aware histogram model is
needed. Rather than ship a wrong "fix", this adds the honest guardrail: `fit_spida_histogram` now returns
`low_density_regime` (true when the fitted N is below `_SPIDA_LOW_DENSITY_N` = 4, where the truncation bias is
appreciable), and `run_spida_analysis` prints a LOW-DENSITY note flagging the N as a lower-confidence, likely
over-estimated value. The fit math is UNCHANGED — high-density results are byte-identical; this only stops a
biased low-density N from being reported as if trustworthy (the anti-black-box move).

Tests (`tests/test_group_a_moments.py`): a new test pins that the flag fires at N=2 and not at N=8; the mechanism
characterization stays green; the recovery golden-master stays a strict-`xfail`, its reason now recording that the
naive fix was verified to over-shoot (so a proper truncation-aware fit is still the open spec). Full gate green.

## [1.6.435] - 2026-07-29
### Fixed — **GLCM texture is computed over the object mask, not the ROI bounding box (N6-3 fix).**
N6-3 confirmed `calculate_glcm_features` ran `graycomatrix` over the ROI *bounding box* with no mask restriction,
so background pixels and the object/background edge entered the co-occurrence matrix — a uniform object (true
texture 0) reported contrast ≈ 11919, entirely edge. The fix adds `_masked_graycomatrix`, which counts only
object–object pixel pairs (the co-occurrence offset per distance/angle, restricted to pairs where BOTH pixels are
inside the mask; symmetric + normed like skimage; vectorised with a bincount per distance/angle so it stays fast
on real objects). A uniform object now gives contrast **0.0**.

Crucially it is **byte-identical to `skimage.feature.graycomatrix(symmetric, normed)` when the mask is the whole
bounding box** — so every existing GLCM number (no ROI, or a rectangular ROI) is preserved; only non-rectangular
objects change, which is exactly the contaminated case. That equivalence is pinned by a new regression guard, and
the pre-existing `test_glcm_features_basic`/`test_glcm_features_with_mask` stay green. The N6-3 strict-`xfail`
golden-master is promoted to passing. (LBP's milder boundary contamination — codes computed over the bbox, then
histogrammed within the mask — is documented but left as-is; no failing golden-master was written for it.)

Full gate green.

## [1.6.434] - 2026-07-29
### Added — **Navigator → generated method panel: the Qt panel + dock action (Method-Widget Spec 1.2/1.3/1.4).**
The "Build method" outcome the navigator was missing: an actual dockable PyCAT analysis panel, generated from the
plan. `GeneratedMethodUI` (`ui/generated_method_ui.py`) subclasses `AnalysisMethodsUI` — inheriting the workflow
header, pixel gate, dock lifecycle, and save/clear footer — and its `setup_ui` walks the plan via the
headless-tested `resolve_plan_sections`, calling each mapped step's bound builder into one layout and rendering a
visible placeholder (`placeholder_text`) for any unmapped step, so a planned step is never silently dropped.
Because every `_add_*` builder already produces complete controls (tag-bound dropdowns, status circles, run
buttons), the panel is fully functional with no per-step UI code. Parameter seeding (Spec 1.3) writes the reviewed
values into the data repository BEFORE the builders run, so each section constructs already seeded; a reviewed
param with no repository home is skipped and logged, never given an invented location. Provenance (`_plan`,
`_intent`, `_review`) is stored on the instance for Specs 2/4.

Dock wiring (Spec 1.4, `navigator_dock.py`): the primary action becomes **🛠 Build method panel**
(`build_method_panel_via_central_manager` constructs and docks the panel through the standard lifecycle), gated by
`run_blocked_reason` so an untrustworthy plan says why rather than building a panel that would produce a wrong
number; the old executor path stays as a secondary **▶ Run the steps that support it**, labelled honestly.

Headlessly verified: `placeholder_text` (names the step + where to run it) is tested, on top of the already-tested
`resolve_plan_sections`/`builder_for`/coverage ratchet; both GUI modules compile and import. The panel's rendering
and the dock button are GUI-bound (PyQt5) — **Spec 1.6 acceptance is a manual napari run** of the cell/condensate
pipeline: answer the navigator, Build, and confirm each section is live and produces the same outputs as the
hand-written `CondensateAnalysisUI`. Full headless gate green.

## [1.6.433] - 2026-07-29
### Added — **Navigator → generated method panel: the plan→sections resolution logic (Method-Widget Spec 1.2 core, headless).**
The join at the heart of the generated-method widget: a plan is an ordered list of steps, a panel is an ordered
list of sections. `resolve_plan_sections(plan)` (`navigator/sections.py`) walks a compiled plan in the executor's
execution order and returns an ordered list of `PlannedSection(op_id, builder_name, owner, gap)` — each mapped
step carrying its `_add_*` builder name, each unmapped step flagged `gap=True` (builder `None`) so the panel
renders a visible placeholder rather than silently dropping a step the plan said was necessary. It is pure over
`(plan, section_bindings)` — no `central_manager`, no Qt — so the whole plan→panel decision is verified
headlessly; the Qt panel (Spec 1.2 proper) only adds calling the bound builder / rendering the placeholder, and
resolves `builder_name` to a live callable at render time via `builder_for`. Order is the executor's, never
re-derived.

Test (`tests/navigator/test_section_coverage.py`, `base`, +1): resolving a real cell plan yields its steps in
execution order (matching `execution_order` exactly), `cellpose`/`feature_analysis.cell_analysis` carry their
real builders, `data_qc.assess` is a gap with no builder, and every gap↔no-builder pairing is consistent. This
completes the headlessly-verifiable core of Spec 1 (1.1 mapping + 1.5 ratchet + this 1.2 resolution logic); the
remaining pieces — the `GeneratedMethodUI` Qt panel, parameter seeding, and the dock's "🛠 Build method panel"
action (1.2 rendering / 1.3 / 1.4 / 1.6) — are GUI-bound and need a manual napari acceptance run. Full gate green.

## [1.6.432] - 2026-07-29
### Added — **Navigator → generated method panel, foundation: the op-id → UI-section mapping + coverage ratchet (Method-Widget Spec 1.1 + 1.5).**
A PyCAT method panel is an ordered sequence of `_add_*` section builders; a Navigator plan is an ordered sequence
of steps. The one missing piece to *generate* a panel from a plan is a mapping from step op-id to section builder.
This lands it as explicit DATA (the `layer_bindings.json` precedent), not a fifth runtime name-similarity guess:

- **`src/pycat/navigator/data/section_bindings.json`** (schema-versioned) — 22 verified bindings covering the
  cell/condensate pipeline end to end. Keyed by the id a `PlanStep` actually carries — catalog op-ids for
  enhancement/segmentation/labels (`cellpose`, `subcellular_segment`, `rolling_ball`, …) AND measure-op ids for
  analysis steps (`feature_analysis.cell_analysis`, `pixel_wise_corr.pearson_manders`). NOTE: the spec's example
  rows used a few ids from the 1.6.422 tree (`background_removal`, `cell_analysis`) that are not the current
  catalog/plan ids; every binding here was verified against the real op the planner emits and the real builder.
- **`src/pycat/navigator/sections.py`** — the loader: `section_for(op_id)`, `mapped_op_ids()`, and
  `builder_for(central_manager, op_id)` which resolves a bound builder or returns `None` — never raises, never
  guesses (the same refuse-to-guess discipline as the execution adapters; the caller renders a visible
  placeholder for an unmapped step rather than dropping it).
- **`tests/navigator/test_section_coverage.py`** (`base`, 5) — the ratchet: the JSON is well-formed; every mapped
  builder EXISTS (AST-scan of the UI source — a stale name would be a silently missing section); coverage only
  grows (floor 22); `builder_for` resolves-or-refuses; and every op a canonical cell/condensate plan selects is
  either mapped or a declared `_KNOWN_GAPS` entry (`data_qc.assess`, `acquisition` — automatic gates, not tools),
  mirroring the route-equivalence declared-gap discipline.

This is the headlessly-verifiable foundation; the generated Qt panel (`GeneratedMethodUI`), parameter seeding, and
the dock's "🛠 Build method panel" action (Spec 1.2–1.4/1.6) build on it next and are GUI-bound. Full gate green.

## [1.6.431] - 2026-07-29
### Fixed — **Size-distribution MLE now honours the detection limit (N6-4 fix — the truncated likelihood, additive).**
N6-4 confirmed that `fit_size_distribution_mle` ignored left-truncation: its whole-sample candidates (lognormal,
gamma, weibull, exponential) used the UNTRUNCATED MLE, and the `xmin` parameter in the signature was dead. When
droplets below the detection limit are missing, that inflates the fitted median (+49% on a true-5 µm lognormal
truncated at 5 µm) and deflates the spread. This wires `xmin` through to a proper left-truncated MLE: when a
detection limit is given, each candidate is fit by numerically maximising `Σ log f(rᵢ) − n·log(1−F(xmin))`
(Nelder-Mead, seeded by the untruncated estimate — no candidate has a closed form once truncated), the AIC uses
that truncated log-likelihood, and the Vuong distinguishability test carries the same per-model truncation
correction so the model comparison stays like-for-like. On the golden-master (a true lognormal, median 5 µm σ 0.5,
truncated at 4 µm) the fit now recovers mu≈log 5 and σ≈0.5 where the untruncated default returns a ~6.5 µm median.

The change is **additive**: `xmin` defaults to `None`, and that path is byte-identical to before (same closed-form
lognormal log-moments, same `scipy .fit(floc=0)`, same log-likelihoods) — every existing caller is unaffected,
pinned by the untruncated characterization tests. The result dict gains `detection_limit_xmin` recording the
truncation applied.

Tests (`tests/test_size_distribution_mle_characterization.py`): the N6-4 strict-`xfail` golden-master is promoted
to a passing test (`test_size_mle_recovers_truth_from_truncated_data_when_xmin_is_honoured`); the untruncated
baseline + bias characterization stay green. Full gate green.

## [1.6.430] - 2026-07-28
### Added — **Condensate MSD adapter — ensemble MSD → anomalous-diffusion fit runs from a navigator plan (spec N2b-4, the "build it" decision).**
`msd_analysis` was a headless skip-stub documented "time-series; not a per-image batch step." That framing was no
longer true: VPT microrheology (1.6.423) already runs a whole-stack detect→link→MSD→fit chain in the batch loop,
and `dynamic_spatial` (1.6.428) now writes a trajectory table — `track_id / frame / y_um / x_um`, exactly
`compute_msd`'s contract. So the N2b-4 decision is resolved by BUILDING the stack-level handler, not documenting
why it can't exist. `replay_msd_analysis` (`analysis_steps.py`) reads the trajectories `dynamic_spatial` linked,
runs the shared `compute_msd` (ensemble MSD with per-track independence) → `fit_anomalous_diffusion` (MSD = 4D·τ^α
by log-log regression), writes `*_msd.csv` + `*_msd_fit.csv`, and stores `D_um2_per_s` in state / data_instance.

Same scale discipline as VPT: a diffusion coefficient is a physical rate (µm²/s), so the handler REFUSES (a
`_msd_scale_validity` flag, no number) when the pixel size is a 1.0 placeholder or the frame interval is missing —
it never emits a pixel²/frame "D". Both diffusion ops (`condensate_physics.compute_msd` +
`.fit_anomalous_diffusion`, each requiring `TRAJECTORIES`, both `needs_pixel_size`) resolve to the one handler; a
`_msd_done` guard keeps a plan holding both from fitting twice. `_msd_params` threads the reviewed minimum track
length (science default 200 frames) / lag cap. Registered net-negative against the `batch_step_registry.py`
complexity ceiling (extended the `analysis_steps` import, swapped the 2-line skip-stub for a 1-line reference).

Tests (`tests/navigator/test_navigator_msd_adapter.py`, `base`, 4): both ops resolve to `msd_analysis`; a guided
`run_plan` over 12 seeded Brownian tracks recovers D ≈ 0.05 µm²/s and equals a direct `replay_msd_analysis` call
bit for bit (both CSVs written); a both-ops plan fits once (guard); and the scale gate refuses a pixel-unit
diffusion through the adapter path. The adapter-coverage guard now pins `msd_analysis`. Full gate green.

## [1.6.429] - 2026-07-28
### Changed — **Coloc "M1"-family labels are now self-distinguishing + adapter-keying convention documented (spec N5, clarity only — no math change).**
**N5a — Costes/Manders label provenance.** A merged colocalization table could show `Costes Automatic Thresholded
M1`, `Mander's M1 value`, and `Mander's k1 value` side by side — three "M1"-family entries that measure different
things (intensity-after-Costes-threshold vs object-mask overlap vs an intensity split coefficient) with nothing on
the label saying how. The arithmetic was already correct (A3 fixed the channel cross-referencing); this makes the
labels self-explaining. The Costes output rows now read `Costes Automatic Thresholded M1/M2 (intensity,
auto-threshold)` (`coloc/analysis.py`), and the object-based rows read `Mander's M1/M2 (object overlap)`
(`coloc/object_based.py`). Both are provenance *suffixes*, not renames — the `…M1`/`…M2` stem is preserved. The
object-based relabel is applied only when building the results table (`process_obca_methods`); the selection key
(`"Mander's M1 value"`) is untouched, so UI wiring and saved presets still resolve. A new
`_M1_FAMILY_LABEL_GLOSSARY` string block in `coloc/analysis.py` maps every M1-family label to its definition and
reference (Costes 2004; Manders 1993), seeding the measurement-ontology roadmap item.

**N5b — adapter-keying convention.** The comment above `_ADAPTERS` (`navigator/executor.py`) previously claimed
the dict is "keyed by the REAL navigator module name" — no longer true now that op-id keys (`vpt.microrheology`,
`spatial_metrology.ripley`, `dynamic_spatial.*`) sit alongside module keys (`segmentation_tools`). Replaced it
with an accurate note of the dual convention: a key is either a module name (resolved for an op-id step via
`_OP_TO_ADAPTER_MODULE`) or an op-id matched directly; `_adapter_for` tries the step name directly first, then the
op→module translation — with guidance on which to use when adding an adapter. Zero behaviour change.

Test: `tests/test_costes_manders.py` updated to look up the suffixed Costes labels. Full gate green.

## [1.6.428] - 2026-07-28
### Added — **Dynamic-spatial adapter — trajectory linking + merge/fission detection run from a navigator plan (spec N2b-3).**
The `dynamic_spatial` batch step was a headless skip-stub, and neither dynamic-spatial op had an `ExecAdapter`, so
a motion/fusion plan reported "run from its panel". This builds the real handler and wires both ops in.
`replay_dynamic_spatial` (`analysis_steps.py`) is self-contained like the VPT terminal: from a segmented
`(T, H, W)` object-label stack it runs the shared `extract_frame_properties` → `link_trajectories` (motion, one
row per detection with a `track_id`) and `detect_merge_fission` (fusion, one row per merge/fission event),
writing `*_dynamic_spatial_tracks.csv` + `*_dynamic_spatial_events.csv` and the matching `state`/`data_instance`
DataFrames. `_dynamic_spatial_params` threads the reviewed max displacement / gap-bridging window / merge
proximity over grounded defaults; pixel size comes from the file metadata, never guessed.

Both the motion op (`dynamic_spatial.link_trajectories`, CREATE) and the fusion op
(`dynamic_spatial.detect_merge_fission`, INTERPRET) resolve to the one handler — a plan that reaches either gets
real numbers — and a `_dynamic_spatial_done` state guard keeps a plan holding *both* from tracking twice. The
handler refuses cleanly (a clear skip, no CSV, no numbers) when state has no 3-D segmented stack: it never
fabricates a per-frame segmentation to invent a "trajectory" from a single frame. Registered net-zero against the
`batch_step_registry.py` complexity ceiling (432) — extended the `analysis_steps` import, swapped the 1-line
skip-stub.

Tests (`tests/navigator/test_navigator_dynamic_spatial_adapter.py`, `base`, 4): both ops resolve to
`dynamic_spatial`; a guided `run_plan` on a synthetic moving-object stack equals a direct `replay_dynamic_spatial`
call column for column (both CSVs written); a plan holding both ops tracks once (guard set); and a 2-D mask
refuses cleanly. The adapter-coverage guard now pins `dynamic_spatial` in the expected batch-step set. Full gate
green.

## [1.6.427] - 2026-07-28
### Added — **Spatial metrology adapter — per-cell Ripley/nearest-neighbour/radial organisation runs from a navigator plan (spec N2b-2).**
The `spatial_metrology` batch step was a headless skip-stub (it printed "skipped in headless mode" and computed
nothing), and the `spatial_metrology.ripley` MEASURE op had no `ExecAdapter`, so a spatial-organisation plan
reported "run from its panel". This builds the real handler and wires it in. `replay_spatial_metrology`
(`analysis_steps.py`) is the cellular analogue of the existing `replay_ivf_spatial_metrology`: it reads the
segmented objects (`puncta_mask`) and the real cell ROIs (`labeled_cells`), extracts per-cell centroids via the
shared `get_puncta_centroids`, and runs the shared `run_all_spatial_metrics` (Ripley's L / nearest-neighbour /
radial density) **within each cell**, writing one flattened row per cell to `*_spatial_metrology.csv` and into
`state['spatial_metrology_df']`. The metrics are keyed per real cell ROI, never whole-frame — a cell with fewer
than two objects is skipped (Ripley/NN are undefined there). The `spatial_metrology.ripley` op requires
`INSTANCE_LABELS`, so the planner chains a segmenter first; `_spatial_metrology_params` takes no knobs (the object
labels and cell ROIs come from state, the metrics run on grounded radial defaults). Registered net-zero against
the `batch_step_registry.py` complexity ceiling (432) by extending the existing `analysis_steps` import and
swapping the 1-line skip-stub for the handler reference.

Tests (`tests/navigator/test_navigator_spatial_metrology_adapter.py`, `base`, 3): the adapter resolves to a real
registered step; a guided `run_plan` on a synthetic two-cell / multi-punctum field equals a direct
`replay_spatial_metrology` call column for column; and the output is keyed per cell (two rows, one CSV), not a
whole-frame collapse. The adapter-coverage guard (`test_every_adapter_targets_a_real_registered_batch_step`) now
pins `spatial_metrology` in the expected batch-step set. Full gate green.

## [1.6.426] - 2026-07-28
### Added — **VPT microrheology adapter — the flagship viscosity terminal runs from a navigator plan (spec N2b-1b).**
1.6.423 built the `vpt_microrheology` batch handler (the full detect→link→MSD→fit→Stokes–Einstein chain). This
wires it into the navigator: the `vpt.microrheology` INTERPRET op now has an `ExecAdapter` →
`vpt_microrheology`, so a bead/viscosity plan computes end to end instead of reporting "run from its panel". The
handler is self-contained (it runs the whole chain from the raw stack), so the terminal carries the workflow; the
upstream detect/link steps stay panel-only until they get their own handlers. `_vpt_microrheology_params` threads
the reviewed bead radius / temperature / min-track-length over grounded defaults — pixel size and frame interval
come from the file metadata (the scale gate), never guessed. The `vpt.microrheology` op already declares
`needs_pixel_size`, so the planner blocks it without calibration; the handler's scale gate refuses a pixel-unit
viscosity as a second line of defence. The "Video Particle Tracking" `CanonicalCase` (already reproducible at the
plan level) now runs end to end (N2b-1c).

Tests (`tests/navigator/test_navigator_vpt_adapter.py`, `base`, 3): the adapter resolves and the plan chains it;
a guided run's viscosity equals the manual `replay_vpt_microrheology` bit for bit on the seeded bead stack; the
scale gate still refuses a pixel-unit viscosity through the adapter path. Full gate green.

## [1.6.425] - 2026-07-28
### Added — **Manuscript Supp panel: runtime by method (the benchmark's performance axis) — Part C, measured not asserted.**
Fig 2 (1.6.424) added the benchmark's *accuracy* axis (Dice vs ground truth); this adds its *speed* axis, so the
benchmark tells a complete story. Part C of the manuscript spec is "for a measurable claim, generate the graph
rather than the assertion" — a "PyCAT is fast" sentence becomes a number on an axis.
- **New panel `supp_runtime`** (`toolbox/manuscript/panels.py`): reads the same `context['benchmark_results']`
  Fig 2 does — each candidate's `runtime_s` (recorded by `benchmark_tools.basic_metrics` whenever the method
  actually ran) — and renders runtime-by-method through the canonical `FigureSpec`. An external/uploaded mask
  carries no runtime (`runtime_s is None`), so it is skipped and the panel greys when nothing was timed — the
  panel never invents a runtime.
- `tests/test_manuscript_panels.py` (`base`, 13: +2) runs two trivial threshold segmenters through
  `run_benchmark` (so real runtimes are recorded) and asserts the figure plots a non-negative measured runtime
  per method; it greys on an external-mask (no-runtime) result. **Remaining (Qt-gated): the interactive gallery,
  the export-widget GIF option, the other Part C timed panels (lazy-load / batch-throughput harnesses), Part D
  recorded demos, Part E FeatureCard discoverability (blocked until the gallery opener exists).**

## [1.6.424] - 2026-07-28
### Added — **Manuscript Fig 2 (benchmark/validation) is now wired — all five panels generate when their data is present.**
The manuscript figure registry left Fig 2 hard-greyed on the belief that no Dice/F1 validation suite existed.
That was premise drift: the earlier increment searched for the spec's named `benchmarks/run_suite.py`, but the
real suite is `toolbox/benchmark_tools.py` — `run_benchmark(...)` produces per-candidate pixel **Dice/IoU** and
matched-detection **F1** against a named ground truth.
- **Fig 2 wired** (`toolbox/manuscript/panels.py`): `available` when `context['benchmark_results']` is a
  **validation-mode** result (a named ground truth with per-method scores); `generate` builds the
  Dice-vs-ground-truth figure through the canonical `FigureSpec` (one point per method, y in [0,1]), so it shares
  the fonts/palette/export of every other panel. F1/IoU are also computed and live in the benchmark table.
- **Honest greying preserved**: a comparison-mode benchmark (no named ground truth) has no per-method Dice, so
  the panel greys — it never invents ground truth. With all five panels now composable, the dead
  `_never`/`_unavailable_generate` stubs are removed.
- `tests/test_manuscript_panels.py` (`base`, 11: +3) builds a real validation-mode result from pre-computed
  masks (no heavy segmenter) and asserts an identical mask scores Dice 1.0, an under-segmented one scores a real
  sub-1 Dice, and the figure renders through `FigureSpec`. **Remaining (Qt-gated): the interactive gallery, the
  export-widget GIF option, Part C timed panels, Part D recorded demos, Part E FeatureCard discoverability.**

## [1.6.423] - 2026-07-28
### Added — **VPT microrheology batch handler — the flagship viscosity chain runs headless (spec N2b-1a).**
The `vpt_microrheology` batch step was a skip-stub (`print('… skipped in headless mode')`) — one of ~35 stubbed
steps the 1.6.415 audit flagged. This builds the real headless handler: `replay_vpt_microrheology` (new
`batch/steps/vpt_steps.py`) runs the whole VPT chain — bead movie → `detect_beads_stack` → `link_trajectories`
→ `compute_msd` → `fit_anomalous_diffusion` → `viscosity_from_diffusion` (Stokes–Einstein) — all pure
numpy/scipy/skimage, no torch. Pixel size threads into detection (coords → µm), the frame interval into the MSD
+ fit (lags → seconds → D), and bead radius + temperature into the viscosity. Registered in `_STEP_MAP` (replacing
the stub) with its op composition `'vpt_microrheology': ('vpt.microrheology',)` in `_STEP_OPERATIONS`; the
registry stays at its 432-line ceiling (the 3-line stub → a 1-line handler reference offsets the import + entry).
- **Scale gate (non-negotiable).** A viscosity in pixel units is meaningless, so the handler REFUSES to emit a
  number when the pixel size is the loader's 1.0 placeholder (`has_real_pixel_size` / `pixel_size_um` → NaN) or
  the frame interval is unknown — it writes a verdict row and stashes `state['_vpt_scale_validity']`, mirroring
  `replay_client_enrichment`'s calibration-verdict pattern, never a pixel-unit η.

Tests (`test_vpt_microrheology_handler.py`, `base`, 3, headless): the full chain recovers the seeded viscosity on
a synthetic bead stack within the golden-master **±15%** band (Brownian beads rendered as Gaussian blobs, D→η via
Stokes–Einstein); the scale gate refuses without a real pixel size and without a frame interval (verdict, no
number). The adapter (`vpt.microrheology → vpt_microrheology`) + the CanonicalCase are the next increment (N2b-1b/c).

## [1.6.422] - 2026-07-28
### Fixed — **`BatchStepResult.to_dict` serializes its typed `AnalysisResult` outputs (completes 1.6.421).**
1.6.421 made a step's `outputs` able to hold a typed `AnalysisResult`, but `BatchStepResult.to_dict` still did a
bare `list(self.outputs)` — leaving un-serialized objects in what is supposed to be a fully-plain, serializable
dict (the whole purpose of `to_dict`). Now each output is serialized to its own dict form (`o.to_dict()` when it
has one), so the envelope is JSON-serializable, and `from_dict` rebuilds an `AnalysisResult` output (detected by
its `operation_id`) back into the typed object — a symmetric round-trip. Non-typed outputs (e.g. a bare layer-id
string) pass through unchanged both ways. Tests (`test_result_models.py`, +2): a `BatchStepResult` carrying an
`AnalysisResult` round-trips serializably (measurements rebuilt to a DataFrame); a plain-string output survives
untouched.

## [1.6.421] - 2026-07-28
### Changed — **Typed result models migration — batch analysis steps now carry a typed `AnalysisResult`.**
The typed result envelopes shipped in 1.6.228; `BatchStepResult` was already adopted by the batch replay (each
step records a typed `ok`/`skipped`/`error` outcome). `AnalysisResult` — the envelope that composes a
measurements table + provenance + calibration for one operation — was defined and tested but adopted nowhere.
This migrates the first consumer: when a batch analysis step succeeds, its outcome now carries the measurement
table it produced as a typed `AnalysisResult` in the `BatchStepResult.outputs` (validated identity — a non-empty
operation_id / entity_type, a real DataFrame — so an impossible result can't be built), not just an `ok` status.
A new `batch/steps/_common.analysis_results_for_step(step_name, state)` maps the analysis steps
(`cell_analysis` → cell, `ivf_droplet_analysis` / `bf_condensate_analysis` → condensate) to the table each writes
and wraps it. Fully additive and non-breaking: it reads the table the step already wrote (from the data instance
or state), changes nothing, and never raises into the run. Tests: the load-bearing logic is pinned HEADLESSLY in
`test_analysis_result_output.py` (`base`, 3 — imports only the non-GUI helper, so the headless gate runs it: the
mapping, reading from state vs the data instance, and the unmapped/missing-table/non-frame `()` edges); the
Qt-bound `BatchWorker` integration is pinned in `test_batch_step_results.py` (+3, GUI lane, which the headless
gate ignores because `batch_processor` imports PyQt5 at module scope) — an analysis step carries the
AnalysisResult (the actual table, nothing re-derived), a non-analysis step and one that wrote no table carry `()`.

## [1.6.420] - 2026-07-28
### Added — **Biological object graph, increment 3 — aggregation (the graph earns its keep).**
Increments 1–2 built the graph and the real cell→puncta join; this is the first thing that USES the structure to
compute something. `ObjectGraph.aggregate_children(key, measurement, reduce)` rolls a child measurement up to its
parent — `count` (children), or `sum`/`mean`/`min`/`max` over the numeric values — the graph-native "total
punctum area in a cell." Non-numeric and NaN values are skipped; a reducer over no numeric values returns `None`,
never a guessed zero. `ObjectGraph.child_summary(parent_type, measurements=, reducers=)` is the per-parent
roll-up table: one plain dict per parent with its own measurements, `n_children`, and a `{measurement}_{reducer}`
field for each requested pair — puncta-per-cell counts and total/mean punctum area from a cell→puncta graph,
general over any parent/child types, changing neither the graph nor its source tables. This is the first step of
the state-vector direction. Tests (`test_object_graph.py`, +3): counts and reductions over real cell→puncta
graphs, the honest `None`/`0`/`ValueError` edges, and the per-parent summary (the orphan punctum contributes to
no cell's rollup).

## [1.6.419] - 2026-07-28
### Added — **Biological object graph, increment 2 — the real cell→puncta join.**
Increment 1 (1.6.227) built the generic read-only object graph (`BiologicalObject` / `ObjectGraph` /
`objects_from_table`) over any table carrying `_pycat_entity_id` + optionally a parent-id column. But PyCAT's
real puncta table doesn't carry the parent's id: a punctum knows the LABEL of the cell it was segmented within
(`'cell label'`, one of `entity_ref._PARENT_COLUMNS`; `make_entity_id` folds that label into the punctum's own id
so per-cell labels don't collide), not that cell's stable id. New `build_cell_puncta_graph(cell_df, puncta_df)`
is the schema-specific join that recovers it — matching each punctum's parent-cell label to the cell's own
`label` in `cell_df` to get the parent cell's `_pycat_entity_id`, so PyCAT's real `cell_df` + `puncta_df` assemble
into ONE graph: cells as roots, their puncta as children. Reuses the existing `_pycat_entity_id` (invents no id),
changes neither table, and — honoring increment 1's explicit-orphan contract — a punctum whose named cell is
absent from `cell_df` surfaces as **unrooted**, never silently rooted. `frame_col` keys the join per frame for a
multi-frame table. Tests (`test_object_graph.py`, +4): the join reproduces the segmentation nesting on real
`stamp_entity_ids`-stamped tables, surfaces an orphan punctum, changes neither table, and degrades to a flat
graph when no parent column is present.

## [1.6.418] - 2026-07-28
### Added — **Two-channel colocalization — correct because it runs WITHIN a segmentation ROI (Phase 3).**
Colocalization is a genuinely new navigator workflow, and the naive version is a documented science trap:
whole-frame Pearson measures the cell shape both channels share (r≈0.99 even for *independent* channels), not
colocalization — `coloc/metrics.py` carries that warning. The fix is to restrict the correlation to a
segmentation ROI:
- **Op contract**: `pixel_wise_corr.pearson_manders` now `requires` an `instance_labels` mask (was a bare
  `intensity_field`), so the planner chains a segmenter before it — a two-channel condensate coloc plan is
  `acquisition → subcellular_segment → pixel_wise_corr.pearson_manders`. It's a `_measure_op`, so no catalog
  regen and the op-count guard (94) is unchanged.
- **New batch handler `replay_pixel_coloc`** (`pixel_colocalization` in `_STEP_MAP`): reads the two channels + the
  segmentation ROI from state and computes Pearson r + Manders overlap / k1 / k2 (the raw, threshold-free
  `coloc/metrics` measures) over the union of the segmented objects — never the whole frame. Adapter
  `pixel_wise_corr.pearson_manders → pixel_colocalization`.

Acceptance gate `tests/navigator/test_navigator_coloc_adapter.py` (`base`, 4): the planner chains a segmenter
before coloc; the guided coefficients equal the manual `coloc/metrics` calls on the same channels within the same
ROI, bit for bit; and the guided within-ROI Pearson **differs** from the whole-frame Pearson (proving the ROI
restriction is applied). Full gate green. (The route-equivalence Workflow-5 gap note is updated: it tests a
different object-based m1/m2 computation, which stays a batch gap.)

## [1.6.417] - 2026-07-27
### Added — **The in-vitro fluorescence droplet MEASUREMENT — a measure→interpret chain; the chain runs end to end (Phase 3).**
1.6.416 shipped in-vitro droplet segmentation with the measurement staged. This wires the measurement as the
**measure→interpret chain** the science owner asked for, completing the in-vitro fluorescence workflow.
- **MEASURE** — `feature_analysis.cell_analysis` on an in-vitro droplet mask now runs a new batch handler
  `replay_ivf_droplet_analysis`: `partition_coefficient_local` on the droplet mask + **raw** image (in-vitro, no
  cells, no dark reference — partition/intensity ratios need the true zero point) → the per-droplet table
  (partition coefficient, dense/dilute intensities, area, circularity). The fluorescence-droplet analogue of the
  brightfield `bf_condensate_metrics` measure. Routed via the run-time state dispatch (`ivf_droplet_mask` present).
- **INTERPRET** — `invitro.size_distribution` fits the droplet size distribution / C_sat (`fit_size_distribution`).
  Its op contract now **requires the measurement table** (`MEASUREMENT_TABLE{observable:size}`, satisfied by
  feature_analysis's `observable:*`) instead of the raw labels, so the planner inserts the measure as its
  dependency — `segment → measure → interpret`. An `in_vitro` context gate + a **positive-only** context bonus in
  `_pick_terminal` make the size-distribution the 'size' terminal **only** in-vitro (an in-cell plan falls back to
  the per-object measure by preference — the bonus never demotes, so the msd/workbook terminal orderings are
  unchanged). New adapter `invitro.size_distribution → ivf_size_distribution`; `n_bins` reviewable.

Acceptance gate `tests/navigator/test_navigator_invitro_adapter.py` (updated): the planner chains
`feature_analysis → invitro.size_distribution` in-vitro (and NOT in-cell); the full plan
`segment → measure → interpret` runs every step; the guided per-droplet table equals the manual
`partition_coefficient_local` and the guided fit equals the manual `fit_size_distribution`, bit for bit. Full gate
green. **The in-vitro fluorescence droplet workflow now runs end to end in the guided flow.**

## [1.6.416] - 2026-07-27
### Added — **The in-vitro fluorescence droplet SEGMENTATION — a context branch, not a new target (Phase 3).**
Droplets and condensates are the same target (synonymous, per the science owner). What distinguishes the in-vitro
workflow from the in-cell one is the **absence of cells**: in-vitro is a whole-field threshold segmentation of
droplets, where in-cell is the per-cell puncta pipeline. So this is a **context** distinction, not a `droplet`
target — carried by a new `in_vitro` context flag that mirrors how brightfield uses the modality gate.
- **`in_vitro` context**: new requirement in the tag vocabulary + `_req_in_vitro` predicate. `ivf_droplet_segment`
  (the extracted `@tags_layer` producer `segment_ivf_droplets` — the same function the panel uses) is tagged
  `requirements=('in_vitro', 'fluorescence')`, so on a `condensate` plan with `in_vitro` + fluorescence it wins
  the segmenter slot via context-score, while the in-cell plan keeps `subcellular_segment` and brightfield keeps
  `bf_segment` (the `fluorescence` gate keeps the in-vitro fluorescence segmenter off brightfield). The target
  stays `condensate` throughout. Catalog regenerated (op count unchanged at 94 — a requirement, not a new op).
- **New batch handler `replay_ivf_droplet_segment`** (`ivf_droplet_segment` in `_STEP_MAP`) drives
  `segment_ivf_droplets` (whole-field otsu/multiotsu/sauvola threshold, no cells) → `ivf_droplet_mask`. Adapter
  keyed on the op-id; the threshold method + min-area knob are surfaced in the param review.
- **Measurement staged.** Like the brightfield chain, the field-summary/size-distribution measurement is the next
  increment; the run-time state dispatch routes the in-vitro measurement step to needs_panel for now (dispatched
  on the produced `ivf_droplet_mask`), never a wrong-handler guess.

Acceptance gate `tests/navigator/test_navigator_invitro_adapter.py` (`base`, 5): `in_vitro` context selects
`ivf_droplet_segment` (in-cell fluorescence stays `subcellular_segment`, brightfield stays `bf_segment`); guided
segmentation equals the manual `segment_ivf_droplets` bit for bit; an edited `min_area` drives the run; the
measurement reports needs_panel. Full gate green. (The canonical cases still use `target='droplet'` — reconciling
them to condensate+in_vitro is a documented follow-on.)

## [1.6.415] - 2026-07-27
### Added — **The brightfield condensate MEASUREMENT — the chain now runs end to end (Phase 3).**
1.6.414 shipped brightfield condensate segmentation but deferred the measurement (neither fluorescence route
fit): the analysis step honestly reported "run from its panel". This wires the correct measurement, completing
the chain. Brightfield condensates are first-class labelled objects (no per-cell nesting), and PyCAT already has
their measurement — `bf_condensate_metrics`, "the brightfield equivalent of `puncta_analysis_func`", which
computes per-condensate optical-density / area / shape (mean/max/integrated OD, area µm², major/minor axis,
eccentricity, circularity, CNR, OD partition coefficient, centroid). It is used today by the in-vitro brightfield
GUI in exactly the cell-less form the navigator needs (`bf_condensate_metrics(raw, mask, None, mpx)`), and is pure
numpy/skimage (route-provable without torch).
- **New batch handler `replay_bf_condensate_analysis`** (`bf_condensate_analysis` in `_STEP_MAP`): runs
  `bf_condensate_metrics` on the `bf_condensate_mask` the segmenter produced, against the **RAW** image (optical
  density is `-log10(I/I0)` — a min-max-normalised image moves the zero point and makes the strongest condensate's
  OD diverge), with `microns_per_pixel` from the data instance's pixel size.
- **`_feature_analysis_step` now routes brightfield → `bf_condensate_analysis`** (was `None`/needs_panel), via the
  same run-time state dispatch 1.6.414 added: it reads which mask the segmenter actually produced
  (`bf_condensate_mask` → the brightfield metrics; `puncta_mask` → the fluorescence per-cell `condensate_analysis`;
  no state → the fluorescence default). No op-graph change — same measurement op, correct batch handler.

Acceptance gate (`test_navigator_brightfield_adapter.py`, updated): the full plan
`bf_preprocess → bf_segment → feature_analysis` now runs every step, and the guided `bf_condensate_df` equals the
manual `bf_condensate_metrics` on the same mask + raw image **bit for bit**; the analysis dispatches on the
produced mask (brightfield → `bf_condensate_analysis`, fluorescence → `condensate_analysis`). Full gate green.

## [1.6.414] - 2026-07-27
### Added — **The brightfield condensate SEGMENTATION chain — the first segmenter that requires preprocessing (Phase 3).**
Brightfield dark-blob segmentation (`segment_bf_condensates`) is meaningless on a raw image, so — unlike the
fluorescence chain, where `segment_subcellular_objects` does its own local processing — brightfield preprocessing
is **mandatory**. The navigator deliberately never auto-inserts preprocessing (the "PDF4" principle), so a
brightfield-condensate plan previously had no preprocessing step and `bf_segment` had no adapter (it silently
reported "run from its panel"). This wires the chain, with one deliberate, science-driven exception to that
principle (Gable's call):
- **New coarse `bf_preprocess` op** (`bf_preprocess_enhanced` → the composite `preprocess_brightfield`'s enhanced
  image), context-gated to `brightfield` and providing a `state:enhanced` product.
- **`bf_segment` now requires that enhanced product** (`_REQUIRES_OVERRIDE` in op_catalog), which makes the
  planner **auto-insert `bf_preprocess` before it** — the first (and only) segmenter to require preprocessing. On
  a brightfield plan the `brightfield` context gate makes `bf_preprocess` (not a fluorescence enhancer) win that
  slot; a fluorescence plan gets no `bf_preprocess`. Op catalog regenerated (93 → 94 ops).
- **Two adapters:** `bf_preprocess` → the `bf_preprocess` batch step (knobs `bg_kernel`, `halo_weight`);
  `bf_segment` → the `bf_condensate_segmentation` batch step (gates `min_diameter_px`, `max_diameter_px`,
  `min_circularity`) — all surfaced in the guided param review.
- **Run-time state dispatch for the coarse callables.** `run_plan`/`resolve_batch_step` now thread `state` into a
  coarse module's batch-step choice, so `feature_analysis_tools` picks its variant from what the segmenter
  actually produced. Brightfield condensates are first-class labelled objects that neither `condensate_analysis`
  (needs a `puncta_mask`) nor `cell_analysis` (a cell-sized min-area filter drops them) measures correctly, so
  the measurement step honestly reports "run from its panel" — never a wrong-handler guess. (The brightfield
  measurement route is the next increment.)

Acceptance gate `tests/navigator/test_navigator_brightfield_adapter.py` (`base`, 6): the planner chains
`acquisition → bf_preprocess → bf_segment` on brightfield (and not on fluorescence); guided `preprocess → segment`
equals the manual `preprocess_brightfield → segment_bf_condensates` bit for bit; an edited `min_diameter_px`
(segmentation) and an edited `bg_kernel` (preprocessing) each make the guided mask equal the manual at that value,
not the default; and the analysis step reports needs_panel for brightfield while staying `condensate_analysis` for
fluorescence. Full gate green.

## [1.6.413] - 2026-07-27
### Fixed — **Green the headless CI lane: two collection-breaking imports guarded, and a platform-fragile unmixing check.**
`pytest -m "core or base"` was red in the headless CI lane (no PyQt5/napari) for three unrelated reasons:
- **Two `base` test modules broke collection.** `test_batch_step_results.py` imports `pycat.batch_processor`
  (PyQt5 at module scope) and `test_video_export_gif.py` imports `pycat.toolbox.video_export_tools` (napari) —
  a *transitive* GUI dependency the AST collection guard's `_GUI_BOUND_PYCAT` allowlist did not cover, so the
  files were imported (not deselected) and raised `ModuleNotFoundError` before `-m` ever ran. Both module paths
  are now in the allowlist, so the guard ignores those files when the GUI stack is absent (and still runs them
  in a full environment).
- **`negative_fraction` miscounted floating-point zero as a negative.** `unmix` via `np.linalg.solve` leaves a
  clean recovery at `-1.35e-15` on the Linux CI LAPACK (exactly `0.0` on Windows), and the strict `< 0` counted
  it — reporting a 50 % "negative fraction" for a *correct* unmix and failing `test_the_negative_fraction_is_the_honesty_check`.
  Now counts only values below a small **scale-relative** floor (`1e-9 × max|a|`), so numerical zero is not a
  negative while a genuine over-subtraction (a fraction of the signal) still is — the same honesty signal, now
  platform-portable. The one production caller (the unmix UI's negative-fraction readout) becomes more honest.

## [1.6.412] - 2026-07-27
### Added — **The condensate segmentation thresholds are now editable in the guided param review (Phase 3 follow-on).**
The navigator's "Review parameters" form surfaced only a cell segmentation step's `cell_diameter`; a **condensate**
segmentation step showed nothing editable, so its six thresholds (`min_spot_radius`, `kurtosis_threshold`,
`local_snr_threshold`, `global_snr_threshold`, `intensity_hwhm_scale`, `max_area_fraction`) were locked at their
grounded defaults — the follow-on 1.6.410 named. Two gaps, both fixed:
- **Target-aware material set.** `parameters._MATERIAL["segmentation_tools"]` is now a target branch: a cell
  target surfaces `cell_diameter`, a condensate target the six thresholds (declared with the grounded defaults
  that equal `segment_subcellular_objects`' signature defaults). `executor._segmentation_params` reads each
  reviewed/edited threshold into the condensate params dict (was `{}`), where `replay_condensate_segmentation`'s
  `params.get(k, default)` picks it up — so an unedited run stays bit-for-bit the manual default and an edited
  threshold reaches the per-cell computation.
- **Op-id resolution (the same dormancy the adapters had).** `material_params` was keyed by toolbox-module name
  while production plans name steps by op-id, so the review surfaced nothing for a real session. It now routes
  through the shared `executor.adapter_module_for` translation (`cellpose`/`subcellular_segment` →
  `segmentation_tools`), so a production cell plan surfaces `cell_diameter` and a condensate plan its thresholds.
The dock renders each `ReviewedStep`'s own (already target/op-id-resolved) params rather than re-looking them up.
Acceptance gate (`test_navigator_condensate_segmentation_adapter.py`, +2): the review surfaces the six thresholds
with grounded defaults, and an **edited `min_spot_radius` makes the guided mask equal the manual result at that
value, not the default** — the edit provably drives the computation. Full gate green.

## [1.6.411] - 2026-07-27
### Fixed — **The navigator execution-adapter layer was DORMANT in the live dock — now it fires (whole-fix).**
When a user clicked "Run analysis," every step reported `needs_panel` and the plan computed **nothing** — for
every workflow, cell and condensate alike, including the adapters shipped in 1.6.332–1.6.410. The dock builds its
plan from a default `NavigatorSession()`, whose step names are **op-ids** (`cellpose`, `subcellular_segment`,
`feature_analysis.cell_analysis`), while every `ExecAdapter` keys on **module names** (`segmentation_tools`, …) —
so `resolve_batch_step` returned `None` for every production step. Four layered root causes, all fixed in one
change so the layer works end to end (a partial fix would have shipped wrong science):
- **Namespace translation.** `executor.py` gains `_OP_TO_ADAPTER_MODULE` + `_adapter_for(step)`, so an op-id step
  resolves to its module adapter (`cellpose`→`segmentation_tools`→`cellpose_segmentation`, etc.). `has_adapter`,
  `resolve_batch_step`, and `run_plan` all route through it.
- **Target-aware selection.** `Planner._resolve_module` now narrows a wildcard `target:*` requirement to a
  propagated *specific* target **for provider lookup only** (leaving the layer-resolver, recursion, and step
  inputs untouched), so a `target:cell` plan is offered the cell segmenter instead of matching the puncta
  segmenter by a preference fluke.
- **Modality/dimensionality-aware selection.** New `_context_score` in `_pick` ranks a context-matched specialist
  (a brightfield op on brightfield, a 3D op on a z-stack) **above** a generic, a context-*unknown* specialist
  **below** a no-gate generic (so an unconfirmed time-series segmenter never out-ranks the plain one), and a
  context-*violated* op out. This is what lets a condensate plan pick `subcellular_segment` on fluorescence yet
  `bf_segment` on brightfield, and a cell plan pick `cellpose_3d` on a z-stack yet `cellpose` on 2D — no per-op
  preference fudge.
- **Vocabulary correction.** `brightfield`/`fluorescence` added to the requirements vocab and mapped to context
  predicates; `bf_segment` tagged `requirements=('brightfield',)`; layer-op contracts now carry
  `requires_context` (was only ever set on the measure path).

The home-dock Run button now drives the same executor path as the menu action (was wired to a non-existent
`run_navigator_plan`). New guard `tests/navigator/test_navigator_adapter_reaches_production_plan.py`: a
default-session **cell** plan resolves `cellpose→cellpose_segmentation` + `cell_analysis`, a **condensate** plan
`subcellular_segment→condensate_segmentation` + `condensate_analysis` — the check every earlier adapter test
skipped by hand-building module-name steps. Three phase-3/executor tests updated to the corrected selection
(the time-series coarsening chain segments with `cellpose` — it feeds `link_condensates`' per-cell condensate
detection, which consumes **cell** labels per its op `purpose`; `subcellular_segment` is now adapted, so the
"no-adapter" guard uses `watershed`). Full navigator suite + route-equivalence oracle green.

## [1.6.410] - 2026-07-26
### Added — **Navigator can now run condensate segmentation — the condensate chain runs end-to-end (Part E Phase 3).**
`segmentation_tools` for a **condensate** target now resolves to the `condensate_segmentation` batch step (was a
documented route-equivalence gap, reported "run from its panel"), producing the `puncta_mask` the condensate
analysis adapter (1.6.409) consumes. So a condensate plan now runs `segmentation → analysis` end to end, mirroring
the cell chain. `params_from` branches on target: cellpose keeps its method/refine + reviewed diameter, while
condensate segmentation reads its six thresholds (`min_spot_radius`, `kurtosis_threshold`, `local_snr_threshold`,
`global_snr_threshold`, `intensity_hwhm_scale`, `max_area_fraction`) from the batch handler's grounded defaults —
which equal `segment_subcellular_objects`' signature defaults, so the guided run computes exactly what the manual
panel does at default settings (no guessed knob; these are the parameters a future review would surface for
editing). Acceptance gate `tests/navigator/test_navigator_condensate_segmentation_adapter.py`: **guided == manual,
bit for bit** (the per-cell loop replicated directly) on a synthetic pre-processed image + labelled cells.

## [1.6.409] - 2026-07-26
### Added — **Navigator can now run condensate feature analysis (execution adapter, Part E Phase 3).**
`feature_analysis_tools` for a **condensate** target now resolves to the `condensate_analysis` batch step (it was
reported as "run from its panel"), so a condensate plan's measurement step auto-runs. Like the cell-analysis
adapter, it takes no run-time parameter — both branches reduce to a single proven function (`cell_analysis_func`
/ `puncta_analysis_func`). Acceptance gate `tests/navigator/test_navigator_condensate_analysis_adapter.py`:
**guided == manual, bit for bit** on a synthetic puncta mask + labelled cells + seeded per-cell table. Also made
the batch handler report a MISSING `puncta_mask` as an error (segmentation must run first) rather than silently
skipping — a dead `RuntimeError` was unreachable behind a combined guard; the legitimate no-cells case still
skips.

## [1.6.408] - 2026-07-26
### Changed — **FRAP / fusion / temperature primary inputs autopopulate (Outstanding-Work Part D, increment 6).**
The FRAP recovery stack, droplet-fusion image stack, and temperature stack dropdowns now bind to
`common.raw_image`. Each is a single-movie workflow, so the raw acquisition is auto-selected; with no `target`
to discriminate, the binding is provenance-based (`prefer: head_of_lineage`) and degrades to an empty dropdown
when several raw images match rather than guessing. This rounds out the resolver wiring for the primary inputs
across the toolbox panels. Guard `test_increment_6_frap_fusion_temperature_primary_inputs_are_bound`.

## [1.6.407] - 2026-07-26
### Changed — **Time-series tracked-droplet layer records lineage (completes the time-series lineage chain).**
`timeseries_invitro_tools.relabel_stack_by_track` (per-frame labels → track ids) is now an
`@tags_layer('ts_track_relabel', role=labels, target=condensate)` op, and the panel records `tag_from_operation`
(source = the per-frame "TSIVF Droplet Labels" layer it recolours) on the "TSIVF Tracked Droplets" layer — the
primary time-series analysis output. On reflection this layer is a distinct analytical product (tracked
identity), not merely a recolouring, so it merits its own lineage; this completes the chain
`raw → ts_droplet_segment → ts_track_relabel`. Catalog regenerated (92 → 93 ops); `relabel_stack_by_track`
removed from `_KNOWN_UNTAGGED`; tests pin the op registration + a `derived_from` edge to the per-frame stack.

## [1.6.406] - 2026-07-26
### Changed — **VPT bead trajectories record lineage — the last gap in the layer-lineage story (Outstanding-Work C1).**
`vpt.analysis.run_vpt_analysis` (the bead detection + linking entry point) is now an
`@tags_layer('bead_track', role=overlay, target=bead)` operation, and the VPT napari adapter records
`tag_from_operation` (source = the bead-channel image, already resolved as a Layer object at the add-site) on the
"Bead Trajectories" tracks layer it creates. So "which image were these beads tracked from?" is answerable, and
the trajectories are queryable by `role=overlay, target=bead`. `overlay` is not a segmentation role, so the fix
from 1.6.401 keeps the tracks' overlay role and gives them a derived edge rather than a mask role. Catalog
regenerated (91 → 92 ops); tests in `test_lineage_recording.py` pin the op registration + a `derived_from` edge
and the UI wiring. With this, every major pipeline output — masks, labels, preprocessed images, droplet masks,
and now trajectories — carries its operation and source lineage.

## [1.6.405] - 2026-07-26
### Changed — **Time-series per-frame droplet segmentation records lineage (Outstanding-Work C1, increment 3 — time-series half).**
`timeseries_invitro_tools.segment_stack_per_frame` is now an `@tags_layer('ts_droplet_segment', role=labels,
target=condensate)` operation (the decorator is transparent — it still returns the labelled stack), and the
time-series in-vitro-fluorescence panel records `tag_from_operation` (source = the input stack) on the per-frame
droplet-label stack it produces. So "which stack is behind these droplet labels?" is answerable, and the earlier
worry that a new segmentation op would ripple into the navigator planner is disproven (the full gate stays green).
Catalog regenerated (90 → 91 ops); `segment_stack_per_frame` removed from `_KNOWN_UNTAGGED`. Tests in
`test_lineage_recording.py` pin the op registration + a `derived_from` edge and the UI wiring. (The tracked-droplet
relabelling — `relabel_stack_by_track` — is a track-id recolouring of an already-tagged stack, left untagged.)

## [1.6.404] - 2026-07-26
### Changed — **In-vitro-fluorescence droplet-mask dropdowns autopopulate (Outstanding-Work Part D, increment 5).**
Unblocked by 1.6.403 (which made `segment_ivf_droplets` a registered op, so its droplet mask carries
`role=labels, target=condensate`): added the `invitro_fluor.droplet_mask` binding key and wired the three
in-vitro-fluorescence "Droplet mask:" consumer dropdowns to it. `target=condensate` discriminates the droplet
mask from a co-existing cell mask, so the analysis panels autopopulate the droplets they just segmented. Guard
`test_increment_5_invitro_fluor_droplet_mask_consumers_are_bound`.

## [1.6.403] - 2026-07-26
### Changed — **In-vitro-fluorescence droplet segmenter extracted to a named, lineage-bearing operation (Outstanding-Work C1, increment 3).**
The in-vitro-fluorescence droplet segmentation lived as an inline `_task` closure inside `invitro_fluor_ui`
(otsu / multiotsu / sauvola / random-forest / advanced-spot dispatch), so it could neither be tested nor carry
lineage. It is extracted VERBATIM into `toolbox/invitro/segmentation.py::segment_ivf_droplets` — a named
`@tags_layer('ivf_droplet_segment', role='labels', target='condensate')` operation — and the panel now records
`tag_from_operation` (source = the pre-processed image) on the droplet mask it produces, so "which image is behind
this droplet mask?" is answerable. Behaviour is unchanged and pinned by `tests/test_ivf_droplet_segmentation.py`,
which characterizes each method's exact output on a fixed scene; the operation catalog was regenerated (90 ops).

## [1.6.402] - 2026-07-26
### Changed — **Resolver wired on the condensate / droplet / cell mask dropdowns (Outstanding-Work Part D, increment 4).**
With role-based resolution repaired (1.6.401), the mask/labels bindings become functional. This increment: fixes
`puncta_analysis.puncta_mask` (`role: mask` → `labels` — puncta layers are labels layers, so the old key matched
nothing, silently leaving the wired puncta-measurement dropdown empty); adds `brightfield.condensate_mask`
(`role=labels, target=condensate`); and wires the brightfield / in-vitro-brightfield condensate & droplet mask
consumer dropdowns to it, plus the brightfield cell-mask consumers to `cell_segmentation.cell_labels`. Because
these keys discriminate by `target`, a condensate-mask slot never auto-selects a co-existing cell mask (and vice
versa) — a behavioural test pins that discrimination. (The fluorescence / time-series droplet masks remain
deferred pending decorators on their inline producers.)

## [1.6.401] - 2026-07-26
### Fixed — **Segmentation outputs no longer silently inherit `role='image'` from their source (tag resolver).**
`mark_derived` decided a derived layer's role with `if via in ('segment', 'segmentation')` — but on the
`tag_from_operation` path `via` is the OP NAME (`bf_segment`, `cellpose`, `subcellular_segment_3d`, …), which
never equals the literal `'segment'`. So every pipeline segmentation output fell into the image→image branch
and **inherited its source image's `role='image'`**, overwriting the correct `mask`/`labels` role. This broke
all role-based resolution for pipeline-derived layers: the `cell_segmentation.cell_labels`,
`puncta_analysis.puncta_mask`, and `common.mask`/`common.labels` bindings could not match the very layers they
target, so those dropdowns silently never autopopulated. `mark_derived` now takes the op's declared output role
(`produced_role`, from the registry's `produces`) and a segmentation output keeps its own role; image→image
derivations (upscale, background subtract) still inherit the source's identity as before. Golden-master tests in
`test_lineage_recording.py` pin both directions. This activates the target-discriminated dropdown bindings
shipped in 1.6.398.

## [1.6.400] - 2026-07-26
### Changed — **Resolver wired on the raw / preprocessed image dropdowns (Outstanding-Work Part D, increment 3).**
Continuing the layer-binding activation: the clearly-labeled raw and preprocessed IMAGE dropdowns across the
brightfield, in-vitro (brightfield + fluorescence), and z-stack panels now carry `common.raw_image` /
`common.preprocessed_image`. These role-only bindings are safe to wire ahead of the mask/labels ones because
`raw` is provenance-discriminated (`prefer=head_of_lineage`) and both degrade to an EMPTY dropdown when several
images match — the resolver never makes a silent wrong pick. Nine dropdowns wired across four panels; guard
`test_increment_3_raw_and_preprocessed_image_dropdowns_are_bound`. (The `common.mask`/`common.labels` fields are
deferred — multiple masks can coexist, so "newest" needs per-panel judgment.)

## [1.6.399] - 2026-07-26
### Changed — **Layer lineage now recorded for z-stack and brightfield condensate segmentation (Outstanding-Work Part C1, increment 2).**
`tag_from_operation` stamps a produced layer with the operation that made it AND a `derived_from` edge to its
source, which is what makes the resolver's "which image is behind this mask?" (head-of-lineage) queries reliable
— but it had few call sites. Increment 2 wires the five add-sites whose producer is a registered `@tags_layer`
op and whose source layer is re-resolvable from its dropdown: z-stack 3D background removal (`bg_removal_3d`),
3D cell segmentation (`cellpose_segmentation_3d`), 3D condensate segmentation (`segment_subcellular_objects_3d`)
in `zstack_segmentation_ui`, and brightfield / in-vitro brightfield condensate masks (`segment_bf_condensates`)
in `brightfield_ui` / `invitro_bf_ui`. Each re-resolves the source Layer via `viewer.layers[dropdown.currentText()]`
(the input variables held only `.data`) and records lineage best-effort, wrapped so it can never break the output
layer. Tests: a `core` wiring guard plus a parametrized mechanism test that each producer records op +
`derived_from` edge. (VPT tracks and in-vitro-fluor droplet masks are deferred — their producers aren't single
decorated functions.)

## [1.6.398] - 2026-07-26
### Changed — **Layer-binding resolver activated on the highest-value dropdowns (Outstanding-Work Part D, increment 2).**
The tag resolver + `layer_bindings.json` were fully built but dormant: passing `binding='<key>'` to a dropdown
makes it auto-select the layer whose TAGS match (surviving renames/reordering) and select NOTHING when several
match ambiguously. Increment 1 (1.6.359) wired the coloc channels. Increment 2 wires the highest-value
tag-discriminated dropdowns — those that match by `target`/`modality`, not merely "newest", so mis-selection risk
is low: cell-analysis cell mask → `cell_segmentation.cell_labels`, puncta measurement mask →
`puncta_analysis.puncta_mask`, cell-segmentation input → `cell_segmentation.input_image`, brightfield inputs →
`brightfield.input_image`, VPT bead channel → `vpt.bead_stack`, invitro-fluor input → `invitro_fluor.input_image`.
`_layer_row` gained a `binding=` parameter that forwards to `create_layer_dropdown`, and the `test_resolver_wired`
guard now sweeps both entry points. New guard `test_increment_2_domain_dropdowns_carry_their_bindings`.

## [1.6.397] - 2026-07-26
### Fixed — **Toolbox `create_layer_dropdown` delegators forward the full `(name_hint, binding)` signature (1.6.376 audit S6).**
There is one canonical `create_layer_dropdown` (`ui/base_ui.py`, tag-aware, with `name_hint` and `binding`); the
nine toolbox UIs each define a thin one-line delegator to it. Eight of the nine had truncated signatures that
dropped `binding=` (four also dropped `name_hint=`), so those panels physically could not pass a resolver binding
through — a structural reason almost no toolbox dropdown was resolver-bound. All nine delegators now carry the
canonical `(self, layer_type, name_hint='', binding='')` signature and forward both keyword args. Contract test
`tests/test_dropdown_binding_forwarded.py` pins that every delegator accepts and forwards both. (Actually passing
`binding='<key>'` at the call sites is the separate downstream resolver task, not part of this change.)

## [1.6.396] - 2026-07-26
### Fixed — **Felzenszwalb region merging now actually runs (distance graph, not similarity graph) (1.6.376 audit S4).**
`segmentation.fz.felzenszwalb_segmentation_and_merging` built its region-adjacency graph in `mode='similarity'`
(edge weight large = alike) but thresholded with `merge_hierarchical`, which merges edges BELOW the threshold —
correct only for a distance graph — using `std(img)**2 / 2`, a variance in the wrong units. The two mistakes
cancelled into a silent no-op: the advertised merge step essentially never fired. It now builds the graph in
`mode='distance'` and sets the threshold to `merge_tol * (img.max() - img.min())`, in the same mean-intensity-
difference units as the edge weights and `_weight_mean_color`. New `merge_tol` parameter (default 0.05, exposed
as a "Merge Tolerance" UI field) controls merge aggressiveness. Golden-master `tests/test_fz_merge.py` pins that
the merge reduces the region count below the initial over-segmentation (old code: equal) and is monotonic in
`merge_tol`.

## [1.6.395] - 2026-07-26
### Fixed — **`tortuosity_per_object` measures the main-axis geodesic, not the spanning-tree edge-sum (1.6.376 audit S3).**
`morphological_complexity_tools.tortuosity_per_object` computed path length as the SUM of every edge in the
skeleton's minimum spanning tree — so a branched fibril (Y/T) folded its side-branch length into the "path",
inflating tortuosity — and took end-to-end distance between the raster-order (row-major) first/last skeleton
pixels, which are not the geodesic endpoints. It now computes the **geodesic diameter**: the shortest path
between the two farthest degree-1 skeleton endpoints, over the straight-line distance between those same
endpoints — the same main-axis definition `fibril_tools` already uses. Golden-master
`tests/test_tortuosity_consistency.py` pins a straight rod ≈ 1.0, an equal-armed right-angle bend ≈ √2, and a
Y-shape < 1.3 (the old MST-sum inflated well past this). Also removed a pre-existing unused `warnings` import.

## [1.6.394] - 2026-07-26
### Fixed — **Pooled molecule counting now applies the camera corrections the single-trace path performs (1.6.376 audit S2).**
`molecular_counting_tools.count_molecules_pooled` — the docstring-"preferred" estimator — omitted the three
corrections `count_molecules_single` applies: it fed the RAW trace to the variance-pair regression (no pedestal
subtraction, so ν was corrupted), took the numerator `y[fast]` with the pedestal still in it, and fitted ν
through the origin with no read-noise floor. **An uncorrected pedestal inflated the pooled N up to ~2.5×** — so
the estimator PyCAT recommends was the biased one.

- The pooled loop now mirrors single exactly: subtract each trace's pedestal (from its own dark tail) BEFORE
  building the variance pairs, feed the corrected trace to `_variance_pairs`, take `y[fast]` from the corrected
  trace, and pool the read-variance so the shared ν fit uses the same free-intercept-when-a-floor-exists rule
  (the two paths now call the identical `_fit_counting_nu`). `count_molecules_single` is untouched (its
  byte-identical golden-master pin still passes).
- `tests/test_pooled_counting_pedestal.py` (`base`, 4) — the missing golden-master: the pooled count is
  pedestal-invariant (the same N with or without an added 800-count pedestal, vs the old ~2.5× inflation), its ν
  tracks the single-trace fitter on the same data, the population median recovers the true count with a pedestal,
  and each trace's pedestal is recovered and reported. Full `pytest -m "core or base"` green.

## [1.6.393] - 2026-07-26
### Fixed — **Radial-localization profile: each bin's count is now paired with its OWN area (1.6.376 audit S1, conclusion-inverting).**
`spatial_metrology_tools.radial_localization_profile` binned the condensate **count** on a radius running
0 = centre → 1 = edge, but the ring **area** on a distance-to-edge field running 0 = edge → 1 = centre — opposite
orientations. So each bin counted the points near the centre yet measured the outer-annulus area: all-central
condensates landed in the `[0, 0.2]` bin paired with the ~9× larger edge area, understating central density and
**inverting the whole radial profile** (a wrong scientific conclusion). A secondary error normalised the point
radius by the inradius, a different length from the centroid→boundary distance.

- Both the points and the ring area are now read from **one** centre-referenced field (0 = centroid, 1 = outermost
  mask pixel), so a point in the central disk and the central-disk area fall in the same bin. Points (in µm) are
  converted to px before indexing the field.
- `tests/test_radial_profile.py` (`base`, 4) — the golden-master test that was missing: central points give a
  count in the first bin *with the small central area* (high density), edge points bin at the outer shell, the
  annulus area increases monotonically centre→edge, and micron/px scales area by mpx² without moving the binning.
  The old inversion fails the count-and-area pairing. Full `pytest -m "core or base"` green.

## [1.6.392] - 2026-07-26
### Changed — **The planner's quality gate now covers MSD/diffusion measurements (navigator quality-gate, op-by-op extension).**
The navigator quality gate (which blocks a measurement the data can't support and says why) is extended to the
condensate-physics diffusion ops — `condensate_physics.compute_msd` and `condensate_physics.fit_anomalous_diffusion`
each gain a `QualityRequirement(needs_pixel_size=True)`. A diffusion coefficient from MSD is a physical rate
(µm²/s); a value in pixels²/frame is not a measurement, so the planner now **blocks** a diffusion plan without a
calibrated pixel size (with the reason in its blockers) and lets it run once a pixel size is present — the same
gate the bead-tracking microrheology already uses. Purely additive, no wiring change (the gate machinery was
built in 1.6.279; this attaches a requirement op-by-op, as that spec intends).

- `tests/navigator/test_quality_gate_planning.py` +2 (`base`): a `diffusion` plan without a pixel size is
  not runnable and names `pixel_size` in its blockers; with one it is runnable. Full navigator suite (154) and
  `pytest -m "core or base"` green.

## [1.6.391] - 2026-07-26
### Added — **A batch config can activate calibrated concentration by curve path (reliability_index roadmap).**
`BatchWorker._process_file` now injects a run-level `calibration_curve_path` from the batch config into the
per-file `state` (mirroring how `_auto_ball_radius` is threaded), so the headless `client_enrichment` step
(1.6.387) loads that curve and produces calibrated concentrations + the reliability calibration factor for
**every** image — without a per-step param. A saved config (or a script) can now turn calibrated concentration
on by adding one key; absent, the step stays a no-op and the batch is unchanged (uncalibrated).

- With this, the calibrated-concentration path is reachable end-to-end from a config file: `calibration_curve_path`
  → `state` → `client_enrichment` loads the curve → concentrations/ΔG through the validity gate → the
  reliability index. Only the Qt field to *set* that config value in the batch dialog remains.
- `tests/test_batch_client_enrichment.py` +2 (`base`): a run-level `calibration_curve_path` reaches the step via
  `state`; its absence leaves the batch uncalibrated. Full `pytest -m "core or base"` green.

## [1.6.390] - 2026-07-26
### Changed — **Batch steps now carry a typed BatchStepResult status; a failed step is a visible partial, not a clean ✓ (exception_context_classification Part 3 / typed-result-models).**
`BatchWorker._process_file` records each replay step's outcome as a typed `BatchStepResult` — `'ok'`,
`'skipped'` (an unregistered step), or `'error'` (with a typed `PyCATError`, remaining steps skipped) — and
returns the list.

- The file loop uses it to **fix a misleading success**: previously a step that failed mid-file was printed to
  the terminal but the file was still recorded `✓` in the batch summary (its per-image folder is actually
  partial). Now a failed step marks the file `⚠ … step '<name>' failed (<error>); remaining steps skipped — the
  per-image output folder has PARTIAL results only`. The run still continues to the next file, exactly as
  before; only the summary status is made honest. The `_consolidated_ok`-gated ✓/⚠ (batch_step visibility guard)
  is preserved (now an `elif`).
- `tests/test_batch_step_results.py` (`base`, 4): all-ok → all `'ok'`; a failing step → `'error'` with the
  message-preserving typed `PyCATError` and the remaining steps not attempted; an unregistered step →
  `'skipped'`; and the `BatchStepResult` envelope invariant (status `'error'` iff a typed error is attached).
  Full `pytest -m "core or base"` green.

## [1.6.389] - 2026-07-26
### Added — **Headless manuscript report generator (manuscript_toolbox).**
`toolbox/manuscript/report.py` — `generate_manuscript_report(context, output_dir)`, the headless counterpart of
the (deferred) Qt gallery: it iterates the `FigurePanel` registry, generates every panel the data supports, and
saves each to a folder (figures → PNG, tables → CSV, text sections → `.txt`), returning a **manifest** — one
entry per panel with `status` `'generated'` (+ path), `'greyed'` (+ the `data_requirement` to satisfy, never a
dead entry), or `'error'` (+ reason). Non-gating: one panel's generation failure is recorded and skipped, never
aborting the rest of the report. `report_summary(manifest)` renders a one-line-per-panel summary.

- `tests/test_manuscript_report.py` (`base`, 4): a partial context generates exactly the panels it can (Fig 3
  PNG + Fig 1 QC CSV land on disk) and greys the rest with their requirement; an empty context greys everything
  and writes nothing; a panel forced to raise is recorded `'error'` while the rest of the report still
  completes. Full `pytest -m "core or base"` green.

## [1.6.388] - 2026-07-26
### Changed — **The batch calibration verdict now feeds the reliability index (reliability_index roadmap).**
Threads the calibration verdict the `client_enrichment` step stashes (1.6.387) into the per-image reliability
context: `BatchWorker._process_file` stashes `state['_calibration_validity']` into `self._last_calibration`
(reset per file), and `_reliability_context_for` adds `calibration=` alongside `image_qc` when a curve ran. The
consolidated table's `_row_reliability_factory` already consumes `reliability_context['calibration']` and
`_score_calibration` already scores a `{valid, level, reason}` verdict — so a **refused** calibration now
hard-overrides the measurement to `unreliable`, a `warn` down-weights it, and a run with **no** curve omits the
factor so the grade stays honestly capped (never assumed passing). No table change.
- `tests/test_batch_client_enrichment.py` +3 (`base`): the verdict reaches the reliability context; it is
  omitted when no curve ran; and the context is `None` for a non-scored batch (no QC cost). With the step
  (1.6.387) + this threading, the reliability `calibration` factor is fully wired end-to-end in the batch;
  only the config-UI to record a curve path per run remains. Full `pytest -m "core or base"` green.

## [1.6.387] - 2026-07-26
### Added — **Headless calibrated concentration in the batch (reliability_index roadmap).**
`replay_client_enrichment` replaces the interactive-only `client_enrichment` batch skip-stub with a real
headless step: when a calibration curve is configured for the run, it converts a droplet field's partition to
real concentrations + K_p + ΔG **through the validity gate** (`check_calibration_validity` — a refused
calibration writes the verdict and **no** concentration, never a plausible fabricated number), writes the
calibrated columns to a per-image `<stem>_client_enrichment.csv`, and stashes the verdict into
`state['_calibration_validity']` for the reliability index to thread.

- Runs **only** the calibrated path — the uncalibrated intensity-ratio `partition_coefficient`
  (`replay_ivf_field_summary`) stands on its own and is never given a calibration verdict. The curve comes from
  `state['calibration_curve']` or a `calibration_curve_path` in params/state (`load_curve`). **Non-breaking:**
  with no curve configured the step is a documented no-op, so an ordinary uncalibrated batch behaves exactly as
  it did when this was a skip-stub. *(Activation wiring — recording a curve path in the batch config UI, and
  threading the stashed verdict into `_reliability_context_for` — is the follow-up; the capability + its safety
  gate are built and tested here.)*
- `tests/test_batch_client_enrichment.py` (`base`, 4): a matching curve writes finite concentrations
  (dense > dilute) + stashes a valid verdict; a mismatched-channel curve is refused with the verdict recorded
  and **no** concentration fabricated; no curve is a non-breaking no-op; a missing droplet mask skips
  gracefully. Full `pytest -m "core or base"` green.

## [1.6.386] - 2026-07-26
### Changed — **Manuscript Fig 1 (QC pipeline) is now wired (manuscript_toolbox).**
The Fig 1 QC panel — previously greyed for want of a composer at the path the spec named — is re-pointed to
`data_qc.runner.run_full_qc` (the acquisition-QC orchestrator; data-QC moved to the `data_qc/` package since the
spec's baseline). `available` gates on a sample image in the context (`qc_image`, a 2-D frame or a stack);
`generate` runs every applicable QC check and returns the report as a table (name / status / value / unit /
headline), passing through the acquisition parameters (`pixel_um`, `na`, `wavelength_nm`, …) when present so an
inapplicable check is reported `na`, never guessed. Four of the five panels now generate; only Fig 2 (benchmark)
stays honestly greyed (no validation/Dice-F1 suite exists in the tree).
- `tests/test_manuscript_panels.py` +1 (`base`): Fig 1 greys without a sample image, and with one produces a
  QC table whose `status` values are the known verdict vocabulary. Full `pytest -m "core or base"` green.

## [1.6.385] - 2026-07-26
### Added — **GIF export path for time-series stacks (manuscript_toolbox — the one genuinely-new video piece).**
`video_export_tools.export_stack_as_gif(stack, output_path, colormap, fps, contrast_limits, loop, …)` — the GIF
counterpart of `export_stack_as_mp4`, for a small, universally-viewable preview loop (a GIF drops straight into
a slide, README, or supplementary file — no codec needed). Same LUT + contrast handling as the MP4 path, now
shared via `_sampled_contrast_limits` / `_lut_rgb`; written with imageio, `loop=0` (forever) by default.
### Fixed — **MP4 export was broken under matplotlib ≥ 3.9** (`matplotlib.cm.get_cmap` was removed in 3.9).
Both export paths now resolve colormaps through a version-tolerant `_get_cmap` (the `matplotlib.colormaps`
registry, with the legacy accessor as a fallback). This latent breakage had no test; the new GIF work added
one.
- `tests/test_video_export_gif.py` (`base`, 5): a (T, H, W) stack round-trips to a (T, H, W, 3) GIF; progress
  fires once per frame; the contrast helper guards a flat stack; the LUT maps min/max to distinct colours; and
  the MP4 path still writes a file after the shared-helper refactor (pyav-gated). Full `pytest -m "core or
  base"` green.

## [1.6.384] - 2026-07-26
### Added — **Manuscript figure-panel registry (manuscript_toolbox Part A).**
New `toolbox/manuscript/panels.py` — a Qt-free registry of `FigurePanel`s that turn PyCAT's rigor/measurement
work into publication figures. Each panel declares, in plain language, exactly what data it needs
(`data_requirement`) and reports whether that data is present now (`available(context)`), so **a panel whose
data is absent is greyed with its requirement as the tooltip — never a dead button, always an instruction.**

- Five panels registered in figure order: **Fig 3 — comparative phenotyping** (fully wired to
  `comparative_figures.condition_comparison_figure`; generates the replicate-aware superplot from a consolidated
  long table), **Supp — reliability/rigor** (`reliability_report_section`), **Supp — calibrated thermodynamics**
  (ΔG via `calibration.delta_g_transfer`), plus **Fig 1 — QC** and **Fig 2 — benchmark**, which grey honestly
  until their composers/data exist (the registry mechanism is robust to a missing composer — it simply reports
  `available=False`). Availability is non-gating: a panel's own probe never breaks the gallery.
- `tests/test_manuscript_panels.py` (`base`, 8): the registry lists the panels in order; every panel states
  what it needs (no dead buttons); with no data all panels grey; Fig 3 generates a real matplotlib figure
  end-to-end from a synthetic long table; the ΔG panel returns a `Parameter` with units; availability gates on
  the right context keys. Full `pytest -m "core or base"` green.

## [1.6.383] - 2026-07-26
### Changed — **`label_and_mask_tools.py` is now a thin re-export shim — label_mask_split COMPLETE.**
The residual label-editing ops — `run_update_labels`, `run_convert_labels_to_mask`, `run_label_binary_mask`,
`run_expand_labels`, `run_mask_logic_merge` (plus the `_napari` lazy-import helper) — move **verbatim** to a new
`toolbox/masks/labels.py`, the last masking concern to find its home. `label_and_mask_tools.py` is now a **39-line
re-export shim** (from 1,592), re-exporting every public name so all callers (`from
pycat.toolbox.label_and_mask_tools import ...`) are unchanged.

- **Characterization-test-first:** `tests/test_mask_labelops_characterization.py` (`base`, 9) was committed green
  on the pre-move code, pinning the exact label/mask outputs (binarize, connected-components, expand, boolean
  AND/OR/XOR merge, label increment/rename) plus the guard/reject paths, patching each function's own
  `__globals__` so it passes **unchanged** after the move. The vanished-function guard records the move in
  `_DELIBERATE`.
- **This completes `label_mask_split`.** `label_and_mask_tools.py` went 1,592 → 39 lines (−98%): the condensate
  wetting physics moved to `condensate_physics/wetting.py`, and the masking halves to
  `toolbox/masks/{measurement, morphology, splitting, labels}.py` — each a focused module, every move verbatim +
  characterization-pinned + guard-checked. The per-file line ratchet is locked at the shim size (39).
- The lazy-stack guard's allowlist (`test_silent_fallbacks`) was repointed from `label_and_mask_tools.py` to
  `masks/labels.py`, following the two `np.asarray(layer.data)` label-editing sites (genuinely 2D) to their new
  home. Full `pytest -m "core or base"` green.

## [1.6.382] - 2026-07-26
### Changed — **Extracted the assessed-split decision path from label_and_mask_tools.py (label_mask_split Step 5).**
`assess_and_split_touching` (228 lines — the morphology-aware decision that assesses *whether* touching masks
should be split: two droplets vs arrested fusion vs chain/aggregate vs single, keyed on the neck ratio) moves
**verbatim** to a new `toolbox/masks/splitting.py`, separated from the raw watershed
(`masks/morphology.split_touching_objects`) it decides over.

- `label_and_mask_tools.py` re-exports the public name, so all callers are unchanged. It was already strongly
  pinned — `tests/test_group_c_geometry.py` characterizes every verdict, the resulting label counts, and the
  neck-ratio physics — so per the spec's "no test, no move" discipline no new test was needed; those pins pass
  **unchanged** through the shim (the byte-identical proof). `label_and_mask_tools.py` drops 493 → 266 lines;
  `masks/splitting.py` is 247.
- The navigator `operation_catalog.json` was regenerated: the one `@tags_layer` operation (`split_assessed`)
  updated its `module`/`source` provenance to `masks/splitting.py` — **only** provenance (89 operations
  unchanged). The vanished-function guard records the move in `_DELIBERATE`. Full `pytest -m "core or base"`
  green.

## [1.6.381] - 2026-07-25
### Changed — **Extracted the binary-morphology concern from label_and_mask_tools.py (label_mask_split Step 4).**
The binary-morphology functions — `generate_cross_structuring_element`, `extend_mask_to_edges`,
`custom_binary_opening`/`custom_binary_closing`, the `binary_morph_operation` orchestrator and its GUI wrapper
`run_binary_morph_operation`, the `opencv_contour_func` area filter, and the watershed `split_touching_objects`
— move **verbatim** to a new `toolbox/masks/morphology.py`.

- `label_and_mask_tools.py` re-exports all eight public names, so every caller (the segmentation modules —
  cellpose, fz, watershed, puncta_refinement, local_thresholding, segmentation_tools — plus
  feature_analysis_tools) is unchanged. The `_napari()` lazy-import helper is copied (a staying label-op still
  uses it). `label_and_mask_tools.py` drops 949 → 493 lines; `masks/morphology.py` is 501.
- **Characterization-test-first:** `tests/test_mask_morphology_characterization.py` (`base`, 8) was committed
  green on the pre-move code, pinning **exact** mask outputs (array / pixel-count equality, never approximate —
  a one-pixel difference propagates into every downstream measurement): the cross element, the non-mutating
  edge-extend, open/close counts, `binary_morph_operation` for every mode, the fill-holes result, the
  contour-area filter, and the GUI wrapper's relabel path. It patches each function's own `__globals__` so it
  passes **unchanged** after the move; `split_touching_objects` stays pinned by `test_group_c_geometry`. The
  vanished-function guard records the move in `_DELIBERATE`.
- The navigator `operation_catalog.json` (a generated artifact) was regenerated: the six `@tags_layer`
  operations among the moved functions updated their `module`/`source` provenance from `label_and_mask_tools`
  to `masks/morphology.py` — **only** provenance, no operation added, dropped, reordered, or otherwise changed.
  Full `pytest -m "core or base"` green.

## [1.6.380] - 2026-07-25
### Changed — **Extracted the mask-measurement concern from label_and_mask_tools.py (label_mask_split Step 3).**
The measurement functions — `measure_region_props` (the pure regionprops core), `run_measure_binary_mask`,
`run_measure_region_props`, and the `MeasurementDialog` property picker — move **verbatim** to a new
`toolbox/masks/measurement.py`, separated from the masking morphology they were filed with.

- `label_and_mask_tools.py` re-exports all four public names, so every caller (batch_step_registry,
  analysis_methods_ui, brushable_table, menu_manager, …) is unchanged. The guarded Qt import block (the
  headless-safe `MeasurementDialog` base) moved with the dialog. `label_and_mask_tools.py` drops 1,303 → 949
  lines; `masks/measurement.py` is 381.
- **Characterization-test-first, per the spec's thin-coverage discipline:** `tests/test_mask_measurement_
  characterization.py` (`base`, 6) was written and committed green on the pre-move code, pinning the exact
  measured numbers at `rtol=1e-9` (area, intensity stats, micron area, relative area/intensity, identity
  stamping) and patching each function's own `__globals__` so it passes **unchanged** after the move — the
  byte-identical proof. The vanished-function guard records the move in `_DELIBERATE`. Full `pytest -m "core
  or base"` green.

## [1.6.379] - 2026-07-25
### Added — **Guided runs are cancellable with a determinate progress bar (navigator_execution_adapters Phase 4).**
The navigator executor `run_plan` gained two Qt-free hooks so a guided run can be observed and stopped without
a second execution engine:

- `should_cancel()` is checked at **each step boundary, before the step runs**. The first truthy check records
  the current step `'cancelled'` and stops — it, and everything after it, never runs, so no step ever computes
  on a cancelled/stale state (the same stop discipline as a blocker). `ExecReport.cancelled` reports it.
- `on_progress(done, total)` fires once per disposed step, **monotonically** 1..N, so a determinate bar is
  possible.

The navigator dock's Run button now drives a determinate `QProgressDialog` with a Cancel button over these
hooks: the bar advances per step, Cancel stops the run at the next boundary, and the summary says where it was
cancelled. Built best-effort — headless / no-Qt falls back to a plain synchronous run.

- Tests: `tests/navigator/test_navigator_cancel_progress.py` (`base`, 5) — cancel stops at a step boundary
  (proven on a real adapter whose computation would otherwise mutate state), and progress is monotonic and
  reaches total (even when cancelled). `tests/test_navigator_dock.py` gained a wiring test: the dock's
  `on_progress`/`should_cancel` are bound to the dialog and the cancelled-summary is reported. Full
  `pytest -m "core or base"` green.

## [1.6.378] - 2026-07-25
### Changed — **Extracted the viewer/layer actions from MenuManager (ui_decomposition Part 2, feature 9 — Part 2 feature extraction complete).**
`_home_fit_view` (53, reset the camera to fit all layers) and `_process_foreign_layers` (48, tag/normalize
layers that appeared from outside PyCAT) — the two remaining non-declaration action methods — move **verbatim**
to `ui/viewer_actions.py`.

- Both keep their original signatures (bodies byte-for-byte identical); `MenuManager` keeps thin generic
  `*args, **kwargs` forwarding wrappers, so every call site resolves unchanged — `_home_fit_view` is wired
  **externally** in `batch_processor.py` (`.triggered.connect(_mm._home_fit_view)`; the wrapper preserves the
  attribute), and `_process_foreign_layers` is called from `_setup_menu_bar` via `self`. The menu structure is
  unchanged — **menu-contract snapshot passes unmodified**.
- `menu_manager.py` drops 1,198 → 1,105 lines (from 2,344 at the start of Part 2). **This completes the Part 2
  feature extraction:** every policy/feature/dialog method is now in its own module (`command_palette`,
  `tag_inspector`, `metadata_dialogs`, `napari_menus`, `session_loader`, `grid_view`, `recorded_steps_dialog`,
  `viewer_actions`). What remains in `menu_manager.py` is menu **declaration + wiring** (`_setup_menu_bar`,
  `_add_toolbox_to_menu`, the `_add_*_to_menu` builders) plus the forwarding wrappers — the spec's intended
  end state. The nominal ≤900 target is bounded below by that declaration core; reaching it would require
  splitting declaration, which the spec's "move, don't improve" caution explicitly defers. Full `pytest -m
  "core or base"` green.

## [1.6.377] - 2026-07-25
### Changed — **Extracted the recorded-steps dialog from MenuManager (ui_decomposition Part 2, feature 8).**
`_show_recorded_steps_dialog` (79, with its nested `_fmt` helper) — the "Recorded Steps" dialog that lists the
analysis steps recorded for batch replay with their parameters — moves **verbatim** to
`ui/recorded_steps_dialog.py`.

- It keeps its original signature (body byte-for-byte identical); `MenuManager` keeps a thin generic
  `*args, **kwargs` forwarding wrapper. The action is wired **externally** in `batch_processor.py` via
  `hasattr(_mm, '_show_recorded_steps_dialog')` / `.triggered.connect(_mm._show_recorded_steps_dialog)` — the
  wrapper preserves that attribute on the instance, so the binding is unchanged. The menu structure is
  unchanged — **menu-contract snapshot passes unmodified**.
- `menu_manager.py` drops 1,273 → 1,198 lines (from 2,344 at the start of Part 2). Full `pytest -m "core or
  base"` green.

## [1.6.376] - 2026-07-25
### Changed — **Extracted metadata comparison from MenuManager (ui_decomposition Part 2, feature 7).**
`_show_metadata_comparison` (62), `_gather_compared_metadata` (20) and `_maybe_warn_metadata_diff` (16) — the
cross-file acquisition-metadata comparison that warns when a newly loaded file's metadata contradicts what is
already loaded — move **verbatim** into the **existing** `ui/metadata_dialogs.py`, joining the metadata dialog
already there (the spec wants dialog + comparison + contradiction listing co-located).

- All three keep their original signatures (bodies byte-for-byte identical); `MenuManager` keeps thin generic
  `*args, **kwargs` forwarding wrappers, so every call site resolves unchanged — including the
  `self._maybe_warn_metadata_diff()` call reached from the load flow. `metadata_dialogs.py` already imports the
  one dependency (`napari`), so no new imports. The menu structure is unchanged — **menu-contract snapshot
  passes unmodified**.
- `menu_manager.py` drops 1,359 → 1,273 lines (from 2,344 at the start of Part 2). Full `pytest -m "core or
  base"` green. This completes the feature list in the spec; the remaining large methods in `menu_manager.py`
  are menu **declaration** (`_setup_menu_bar`, `_add_toolbox_to_menu`), which the spec keeps in place.

## [1.6.375] - 2026-07-25
### Changed — **Extracted the managed grid view from MenuManager (ui_decomposition Part 2, feature 6).**
`_toggle_grid_view` (83) and `_apply_managed_grid` (84, with its nested `_anchor_key` helper) — the napari
grid-view manager that arranges visible layers in a grid while keeping an anchor layer fixed, and keeps the
grid in sync as layer visibility/membership changes — move **verbatim** to `ui/grid_view.py`.

- Both methods keep their original signatures (bodies byte-for-byte identical); `MenuManager` keeps thin
  generic `*args, **kwargs` forwarding wrappers, so every call site resolves unchanged — the grid menu action,
  the internal `_toggle_grid_view → _apply_managed_grid` call, and the `_on_grid_layer_vis_changed` /
  `_on_grid_layers_changed` layer-event handlers (which stay in MenuManager) all still reach it. The menu
  structure is unchanged — **menu-contract snapshot passes unmodified**.
- `menu_manager.py` drops 1,518 → 1,359 lines (from 2,344 at the start of Part 2). Full `pytest -m "core or
  base"` green. Remaining Part 2 feature: metadata comparison.

## [1.6.374] - 2026-07-25
### Changed — **Extracted the session loader from MenuManager (ui_decomposition Part 2, feature 5).**
`_open_session_loader` (154) and `_load_discovered_session` (37) — the "Load Session" discovery dialog that
scans a folder for saved PyCAT sessions and reopens the chosen one — move **verbatim** to `ui/session_loader.py`,
along with the two module-level maps they (and only they) use: `_SESSION_METHOD_SWITCH` (UI class → switch method)
and `_SESSION_METHOD_BY_DATA` (data key → UI class).

- Both methods keep their original signatures (bodies byte-for-byte identical); `MenuManager` keeps thin generic
  `*args, **kwargs` forwarding wrappers, so the `_add_file_io_methods_to_menu` action and the internal
  `_open_session_loader → _load_discovered_session` call both resolve unchanged. The menu structure is unchanged —
  **menu-contract snapshot passes unmodified**.
- `ui_modules.py` now re-exports `_SESSION_METHOD_SWITCH` / `_SESSION_METHOD_BY_DATA` from their new home
  (`session_loader`) rather than `menu_manager`, so `ui_modules._SESSION_METHOD_*` (used by
  `test_session_load_lazy_image`) keeps working.
- `menu_manager.py` drops 1,720 → 1,518 lines (from 2,344 at the start of Part 2). Full `pytest -m "core or base"`
  green. Remaining features: grid view, metadata comparison.

## [1.6.373] - 2026-07-25
### Changed — **Extracted native-menu manipulation from MenuManager (ui_decomposition Part 2, feature 4).**
`_hide_napari_native_menus` (88) and `_disable_napari_open_actions` (146) — which hide/reorder napari's built-in
menus and disable its file-open actions (PyCAT owns loading) — move **verbatim** to `ui/napari_menus.py`.

- Both keep their original signatures (bodies byte-for-byte identical); `MenuManager` keeps thin generic
  `*args, **kwargs` forwarding wrappers, so every call site works unchanged — `_setup_menu_bar` → `_hide_…` →
  `_disable_…` still resolves through the wrappers. The menu structure is unchanged — **menu-contract snapshot
  passes unmodified**. `menu_manager.py` drops 1,946 → 1,720 lines (from 2,344 at the start of Part 2).
- Full `pytest -m "core or base"` green (1991 passed). Remaining features: metadata comparison, grid view,
  session-loading orchestration.

## [1.6.372] - 2026-07-25
### Changed — **Extracted the metadata dialog from MenuManager (ui_decomposition Part 2, feature 3).**
The 157-line `_show_metadata_dialog` (the "Show Metadata" dialog — per-file acquisition metadata, the
contradiction panel, CSV export, with its `_fmt` / `_fmt_interval` / `_populate` / `_export` helpers) moves
**verbatim** to `ui/metadata_dialogs.py`; `MenuManager._show_metadata_dialog` becomes a thin wrapper.

- The extracted function keeps the same `self` parameter so its body is byte-for-byte the original method's; the
  module carries the Qt/napari imports the dialog needs. The menu action and its label are unchanged — the
  **menu-contract snapshot passes unmodified**. `menu_manager.py` drops 2,099 → 1,946 lines.
- Full `pytest -m "core or base"` green (1991 passed). Remaining features: metadata comparison, grid view,
  native-menu manipulation, session-loading orchestration.

## [1.6.371] - 2026-07-25
### Changed — **Extracted the tag inspector from MenuManager (ui_decomposition Part 2, feature 2).**
The 121-line `open_tag_inspector` (the per-layer tag inspector/editor dialog, with its `_current_layer` /
`_refresh_table` / `_apply_override` / `_nm` helpers) moves **verbatim** to `ui/tag_inspector.py` as
`open_tag_inspector(self)`; `MenuManager.open_tag_inspector` becomes a thin wrapper that calls into it.

- The menu action and its label are unchanged — the **menu-contract snapshot passes unmodified**. Behaviour is
  identical (the extracted function keeps the same `self` parameter, so its body is byte-for-byte the original
  method body). `menu_manager.py` drops 2,216 → 2,099 lines.
- Full `pytest -m "core or base"` green (1991 passed). Remaining features: grid view, metadata dialogs,
  native-menu manipulation, session-loading orchestration.

## [1.6.370] - 2026-07-25
### Changed — **Extracted the command palette from MenuManager (ui_decomposition Part 2, feature 1).**
`menu_manager.py`'s `MenuManager` class is 2,165 lines and mixes menu wiring with feature logic. This starts
Part 2 by moving the command-palette feature to its own module.

- The 132-line `open_command_palette` (the fuzzy action-search dialog, with its `_score` / `_refresh` /
  `_activate` / `_key` helpers) moves **verbatim** to `ui/command_palette.py` as `open_command_palette(mm)`;
  `MenuManager.open_command_palette` becomes a thin wrapper that calls into it. The menu action and its label are
  unchanged — the **menu-contract snapshot (`test_menu_contract`) passes unmodified**.
- `menu_manager.py` drops 2,344 → 2,216 lines. Behaviour is identical (the body references only `self`, bound
  via a one-line shim, and locally-imported Qt widgets). Full `pytest -m "core or base"` green (1991 passed).
  Remaining features to extract: tag inspector, grid view, metadata dialogs, native-menu manipulation,
  session-loading orchestration.

## [1.6.369] - 2026-07-25
### Changed — **`ui_modules.py` is now a thin re-export shim — ui_decomposition Part 1 COMPLETE.**
The last class in the file, the 686-line `ToolboxFunctionsUI` (the main toolbox panel), moves **verbatim** to
`ui/toolbox_functions_ui.py`.

- `ui_modules.py` drops **816 → 133 lines** — from the **3,266-line** file that was the largest in the project
  (before increment 1) to a thin shim that just re-exports `BaseUIClass`, the `AnalysisMethodsUI` hierarchy,
  `ToolboxFunctionsUI`, and `MenuManager`. Every `from pycat.ui.ui_modules import …` keeps working; no behaviour
  changed.
- `ToolboxFunctionsUI` references nothing else in `ui_modules` (clean move, no cycle) — it inherits `BaseUIClass`
  from the `base_ui.py` leaf plus its six widget mixins, all imported directly in the new module.
- Contract tests updated (`test_ui_structure` now checks each of the three UI class groups in its new home +
  re-export; 29 more methods in `_DELIBERATE`). Full `pytest -m "core or base"` green (1991 passed).
  **Part 1 done; remaining: Part 2 (`menu_manager.py`, 2,344 lines).**

## [1.6.368] - 2026-07-25
### Changed — **Extracted the `AnalysisMethodsUI` hierarchy to its own module (ui_decomposition, increment 2).**
The condensate / colocalization / general / time-series / fibril analysis panels — `AnalysisMethodsUI` and all
nine of its subclasses (plus the `CollapsibleSection` helper) — move **verbatim** from `ui_modules.py` to a new
`ui/analysis_methods_ui.py`.

- **The whole hierarchy moves together** (not split into a separate `timeseries_condensate_ui.py` as the spec
  sketched): `TimeSeriesCondensateUI` inherits `AnalysisMethodsUI` **and** `AnalysisMethodsUI` references
  `TimeSeriesCondensateUI` (the analysis-switcher) — a mutual dependency. Keeping them in one module makes that
  cycle-free; the module imports `BaseUIClass` from the `base_ui.py` leaf, so there's no cycle back to
  `ui_modules`.
- `ui_modules.py` drops **2,410 → 816 lines** (it was 3,266 before increment 1). It re-exports every moved class,
  so `from pycat.ui.ui_modules import AnalysisMethodsUI` (etc.) keeps working. No behaviour changed.
- Contract tests updated (`test_ui_structure` now pins each moved class in its new home + re-export via a shared
  helper; 38 methods recorded in `_DELIBERATE`). Full `pytest -m "core or base"` green (1991 passed). Remaining:
  `ToolboxFunctionsUI` → its own module, then `menu_manager.py` (Part 2).

## [1.6.367] - 2026-07-25
### Changed — **Extracted `BaseUIClass` to its own leaf module (ui_decomposition, increment 1).**
`ui/ui_modules.py` was the largest file in the project (3,266 lines). This moves its foundation — the
761-line `BaseUIClass` that every PyCAT UI class inherits — plus the scroll-guard helper cluster
(`_WheelScrollGuard`, `_wheel_guard`, `guard_wheel`, `_relax_min_widths`, `_apply_scroll_guard`) to a new
`ui/base_ui.py`, **verbatim**.

- **`base_ui.py` is a leaf module** (imports only Qt / napari / `debug_log`), so the subclass modules can
  import `BaseUIClass` without an import cycle back through `ui_modules`. That is why the base moved **first**,
  deviating from the spec's "move the base last" ordering (documented in the module docstring).
- `ui_modules.py` drops **3,266 → 2,410 lines**; it re-imports `BaseUIClass` (+ the helpers) right before the
  classes that inherit it and re-exports them, so `from pycat.ui.ui_modules import BaseUIClass` / `guard_wheel`
  (used by several mixins and toolbox UIs) keep working unchanged. No behaviour changed.
- Contract tests updated to the new structure: `test_ui_structure` pins `BaseUIClass` in `base_ui.py` + its
  re-export (mirroring the MenuManager precedent); `test_tag_resolver`'s dropdown-binding check reads
  `base_ui.py` where `create_layer_dropdown` now lives. Full `pytest -m "core or base"` green (1991 passed).
  Remaining: `ToolboxFunctionsUI`, the `AnalysisMethodsUI` family, and `TimeSeriesCondensateUI` → their own
  modules; then `menu_manager.py` (Part 2).

## [1.6.366] - 2026-07-25
### Changed — **Decomposed the 520-line `_add_lazy_preprocess_stack` — its 358-line `_on_build` was the longest handler in the project (ui_builder_split 5 of 5 — COMPLETE).**
Lazy zarr-backed stack preprocessing / background removal with a caching layer and parallel workers. Pure Qt +
worker orchestration, no numerics changed.

- **Widget factory** `_lazy_widgets` builds the panel widgets **verbatim** → `SimpleNamespace`; the three L1
  handlers (`_lazy_on_check_correlation`, `_lazy_load_from_cache`, `_lazy_on_discard_cache`) moved to module level.
- **`_on_build` (358 lines) decomposed**: a dispatcher `_run_lazy_preprocess` (validate → prepare source →
  resolve cache → dispatch) delegating to `_lazy_prepare_source` (frame-range + XY-ROI, **keeping its two nested
  lazy-array adapter classes**), `_lazy_resolve_cache` (cache dir/paths + reload dialog), and the worker helpers
  `_lazy_start_worker` / `_lazy_run_combined` / `_lazy_start_bg`, which thread a `ctx` namespace and run the
  original worker-setup + callback bodies **verbatim** — so worker ordering and progress wiring are unchanged.
- `_add_lazy_preprocess_stack` is now a 33-line orchestrator; every resulting function is ≤120 lines. Pinned by
  `test_ui_builder_split` (the `_ts_workers` / `_ts_zarr_preproc` / `_ts_zarr_bgrem` contract, unmodified) + a
  headless construction smoke. Removed **both** the builder and the 358-line `_on_build`; **`_MAX_LONG_FUNCTIONS`
  lowered 115 → 113.**
- **ui_builder_split is COMPLETE** — all five of the project's longest functions (the UI builders) are
  decomposed; the ratchet went **120 → 113** across the five releases.

## [1.6.365] - 2026-07-25
### Changed — **Decomposed the 549-line `_add_run_ts_cellpose` UI builder — its 311-line `_on_run` was the deepest handler (ui_builder_split, 4 of 5).**
Time-series keyframe cell segmentation (Cellpose / StarDist / Random-Forest / Multi-Otsu). Pure Qt + dispatch, no science changed.

- **Widget factories** `_ts_cp_input_method_widgets` / `_ts_cp_option_widgets` build the seg-channel/method and
  upscale/max-proj/transfection/progress widgets **verbatim** and return a `SimpleNamespace`.
- **`_on_run` (311 lines) split three ways:** a dispatcher `_ts_cp_run` (validate + apply ROI/frame-range +
  choose method) delegating to per-method segmentation functions `_ts_cp_seg_otsu` / `_ts_cp_seg_rf` /
  `_ts_cp_seg_cellpose`, plus the worker callbacks `_ts_cp_on_finished` and `_ts_cp_transfection_filter`
  extracted to module level. Each unpacks the namespace and runs the original body **verbatim**.
- `_add_run_ts_cellpose` is now a 39-line orchestrator; every resulting function is ≤120 lines. Pinned by
  `test_ui_builder_split` (the `_ts_cellpose_worker` contract, unmodified) + a headless construction smoke.
  Decomposing it removed **both** the outer builder and the 311-line `_on_run` from the long-function set;
  **`_MAX_LONG_FUNCTIONS` lowered 117 → 115.** Remaining: `_add_lazy_preprocess_stack` (520, 358-line handler).

## [1.6.364] - 2026-07-25
### Changed — **Decomposed the 492-line `_add_run_timeseries_condensate_analysis` UI builder (ui_builder_split, 5 of 5 by size but 3rd shipped).**
The time-series condensate-analysis panel split the same way — pure Qt construction, no analysis changed.

- **Widget factories** `_ts_cond_input_widgets` / `_ts_cond_refine_widgets` build the input/option and
  refinement/run-control widgets **verbatim** and return a `SimpleNamespace` of handles (including the shared
  `_worker_ref` / `_run_ripley_ref` / `_mask_name_ref` state refs).
- **Handlers** `_ts_cond_run` (validate + launch worker), `_ts_cond_on_finished` (present results),
  `_ts_cond_append_ripley` (post-run Ripley's L / PCF), and `_ts_cond_record` (batch record) move to module
  level; each unpacks the namespace and runs the original body **verbatim**. GUI imports stay function-local so
  the module remains headless-importable.
- `_add_run_timeseries_condensate_analysis` is now a 35-line orchestrator; every resulting function is ≤120
  lines. Pinned by `test_ui_builder_split` + a headless construction smoke. **`_MAX_LONG_FUNCTIONS` lowered
  118 → 117.** Remaining (the two hardest, with 311/358-line handlers): `_add_run_ts_cellpose` and
  `_add_lazy_preprocess_stack`.

## [1.6.363] - 2026-07-25
### Changed — **Decomposed the 595-line `_add_condensate_physics` UI builder (ui_builder_split, 2 of 5).**
The condensate-biophysics panel (MSD, intensity/Csat, kinetics, QC, survival) split the same way as builder 1 —
pure Qt construction, no science changed.

- **Per-tab builders:** `_build_msd_tab`, `_build_intensity_tab`, `_build_kinetics_tab`, `_build_qc_tab`,
  `_build_survival_tab` (each returns its tab widget; the main function wires the static tab and hands the four
  time tabs to `_sync_time_tabs`).
- **Widget factories** for the two big tabs (`_msd_widgets`, `_qc_widgets`) build widgets **verbatim** and
  return a `SimpleNamespace`; **run handlers** `_run_msd_analysis` / `_run_qc_analysis` unpack it and run the
  original handler bodies **verbatim** (dedented). The three smaller tabs keep their handlers nested.
- `_add_condensate_physics` is now a 92-line orchestrator; every resulting function is ≤120 lines. Pinned by
  `test_ui_builder_split` (the `_msd_worker` / `_hist_worker` / `_qc_worker` attribute contract, unmodified) +
  a headless construction smoke. **`_MAX_LONG_FUNCTIONS` lowered 119 → 118.** Remaining: `_add_run_ts_cellpose`
  and the two `timeseries/ui.py` builders.

## [1.6.362] - 2026-07-25
### Changed — **Decomposed the 638-line `_add_advanced_analysis` UI builder (ui_builder_split, 1 of 5).**
The five longest functions in PyCAT are all UI builders. This splits the largest into small helpers, none over
the 120-line ratchet — pure Qt construction, no science, no numerics changed.

- **Per-tab builders:** `_build_morphological_tab`, `_build_dynamic_tab`, `_build_organizational_tab`.
- **Widget factories** build each tab's widgets **verbatim** and return a `SimpleNamespace` of handles:
  `_dynamic_linking_widgets`, `_dynamic_run_widgets`, `_organizational_widgets`.
- **Run handlers** `_run_dynamic_analysis` / `_run_organizational_analysis` take the namespace, unpack it to
  locals in one statement, then run the original handler bodies **verbatim** (dedented). `_add_advanced_analysis`
  is now a 116-line orchestrator that wires the three tabs.
- Construction order, parenting and signal connections are preserved; every resulting function is ≤120 lines.
  Pinned by `test_ui_builder_split` (the `_morph_worker` / `_org_worker` attribute contract, unmodified) and a
  new headless construction smoke (`test_advanced_analysis_builder.py`). **`_MAX_LONG_FUNCTIONS` lowered
  120 → 119.** Remaining builders: `_add_condensate_physics`, `_add_run_ts_cellpose`, and the two
  `timeseries/ui.py` builders.

## [1.6.361] - 2026-07-25
### Changed — **Sub-split `vpt/detection.py` (part 2: GPU kernels) — vpt_detection_subsplit, complete.**
The pure GPU code moves to its own module; detection.py is left holding the core detection path + the
equivalence gate.

- **`vpt/gpu.py` (new)** — `blob_log_gpu` (the cupy Laplacian-of-Gaussian blob detector, GPU counterpart of
  skimage's `blob_log`) and `_gpu_build_id` (cupy build fingerprint) moved VERBATIM. cupy is imported lazily
  inside the functions, so the module imports on a CPU-only box. Neither calls a detection function, so there
  is no import cycle.
- **The equivalence GATE stays in `detection.py`** — `gpu_matches_cpu`, `_run_gpu_equivalence_check`, and the
  session memo `_GPU_EQUIV_CACHE` are coupled to `detect_beads_frame` and are monkeypatched **on the detection
  module** by `test_vpt_equivalence_guard_memo`. Moving them would have forced test changes, and "the
  equivalence guards pass unmodified" is law — so only the pure kernels moved. `detection.py` back-imports the
  two kernels (its core path calls `blob_log_gpu`) and re-exports them.
- detection.py **1,773 → 1,261 lines** across the two parts. The GPU/CPU/serial equivalence guards, the
  detect-beads characterization (coords, sigma, count **and order**), and the **~8.325 viscosity chain
  baseline** all pass **unmodified**.
- **Revert condition (standing VPT rule):** if the viscosity baseline shifts from ~8.325, revert.

## [1.6.360] - 2026-07-25
### Changed — **Sub-split `vpt/detection.py` (part 1: linking + artifacts) — vpt_detection_subsplit.**
The VPT decomposition left `detection.py` at 1,773 lines / 34 functions, larger than several files that were
themselves decomposition targets. This is the low-risk first pass (the two clearest wins, per the spec).

- **`vpt/linking.py` (new)** — `estimate_linking_distance_um` and `assess_linking_conditions` were misfiled in
  detection.py: both are **linking** concerns, not detection. Moved VERBATIM to their proper home — a
  correctness-of-organisation fix as well as a size reduction. `estimate_linking_distance_um` lazily imports
  `detect_beads_frame` from detection (verified import-order-independent — no cycle).
- **`vpt/artifacts.py` (new)** — `build_hot_pixel_mask` and `dedup_detections_ring_merge` (rejection/merge
  around the core detection path) moved VERBATIM.
- `detection.py` re-exports all four and back-imports the two artifact functions (its core path calls them);
  it drops **1,773 → 1,340 lines**. No detection numerics touched. The GPU/CPU/serial equivalence guards, the
  detect-beads characterization (coordinates, sigma, count **and order**), and the **~8.325 viscosity chain
  baseline** all pass unmodified. Next: `blob_log_gpu` + the GPU dispatch → `vpt/gpu.py`.
- **Revert condition (standing VPT rule):** if the viscosity baseline shifts from ~8.325, revert.

## [1.6.359] - 2026-07-25
### Added — **Resolver activation on the object-coloc dropdowns (Outstanding-Work spec D, increment 1).**
The tag resolver + `layer_bindings.json` + tag-based autopopulate were fully built but **dormant**: of ~170
`create_layer_dropdown` call sites, essentially none passed a `binding=`, so the mechanism had no consumers.

- The object-based colocalization mask dropdowns (`ui/ui_analysis_mixin.py`) now pass
  `binding='colocalization.channel_a'` / `channel_b`. These keys are **deliberately ambiguous** (no `prefer`):
  with two object masks present the resolver selects **nothing** and names both candidates, rather than
  guessing a channel pairing — because a colocalization run on the wrong pairing yields a plausible-but-wrong
  number the user would never question. Binding is purely additive (it drives auto-selection, not the listing;
  the dropdown still lists every Labels layer and stays user-editable).
- **Test** (`tests/test_resolver_wired.py`): the "0 → N bound" regression guard — the object-coloc dropdowns
  carry their channel bindings, and a package-wide sweep asserts **every** wired `binding=` names a real key in
  `layer_bindings.json` (so a future typo'd binding is caught wherever it is added). Binding *semantics* remain
  covered by `test_tag_resolver`.
- **Remaining (increment 2+):** the cell-analysis input/labels, puncta mask, VPT bead-stack, and common.*
  image dropdowns — each needs its binding key chosen to match the dropdown's semantic role (a wrong key risks
  the silent mis-selection the resolver exists to prevent), so they are wired deliberately, not in bulk.

## [1.6.358] - 2026-07-25
### Added / Fixed — **Tag vocabulary + frame-interval contradiction (Outstanding-Work specs F1 + F2).**
Two small metadata-subsystem items, grouped.

- **F1 — dimensionality/modality vocabulary + the TZYX classifier.** Added `3d+t` to `dimensionality` and
  `phase` / `DIC` / `trace` to `modality` (`utils/layer_tags.py`). Fixed the classifier (`file_io/tagging.py`):
  it checked `n_t>1` before `n_z>1`, so a volumetric time-lapse (both a time and a z axis) was tagged `2d+t`,
  silently dropping the z dimension — even though `axis_order` already recorded `TZYX`. A TZYX stack now tags
  `3d+t`. Test `tests/test_dimensionality_3dt.py`.
- **F2 — frame-interval contradiction surfaced.** The loader's `reconcile_frame_interval` already set
  `common['frame_interval_inconsistent']` (nominal cadence vs the interval derived from per-frame timestamps),
  but **nothing read it** — so the user never learned the cadence disagreed, even though a wrong interval
  silently corrupts every time-derived quantity (D, viscosity, MSD, moduli axis). `detect_contradictions`
  (`utils/metadata_contradictions.py`) now emits a **critical** `frame_interval_inconsistent` row, and
  `_engine_input` threads the reconciled fields from `common` so the metadata panel raises the red indicator.
  Cry-wolf preserved (a consistent file raises nothing). Test `tests/test_frame_interval_contradiction.py`.
- **F3 (loader gain/binning/temperature promotion + non-MicroManager per-frame times) deferred** — a
  lower-value display convenience needing deeper per-reader loader work; documented as a follow-up.

## [1.6.357] - 2026-07-25
### Added — **Lineage recording at the first batch of pipeline add-sites (Outstanding-Work spec C1, increment 1).**
`tag_from_operation` — which stamps a layer with the operation that produced it AND records a `derived_from`
edge to its source — had **zero real call sites**, so the resolver's head-of-lineage and "labels derived from
this image" queries always fell back to weak guesses. This wires it at the highest-value add-sites (the outputs
later steps consume):

- **Cellpose → cell labels** (`segmentation/cellpose.py`): the output labels now record `op='cellpose'` and a
  `derived_from` edge to the input image layer.
- **Subcellular segmentation → puncta / refined masks** (`segmentation/subcellular.py`): both masks record
  `op='subcellular_segment'` derived from the pre-processed image.
- **Background removal → preprocessed image** (`image_processing/background.py`): replaces a raw-string
  `mark_derived('background_subtract')` with `tag_from_operation` through the **registered** op (`rolling_ball`).
- **Preprocess → preprocessed image** (`image_processing/preprocessing.py`): records `op='preprocess'` derived
  from the active image.
- **Enabling fix:** `add_image_with_default_colormap` (`ui/ui_utils.py`) now **returns** the created layer — it
  previously discarded it (returned `None`), which is why `_add_image`'s captured layer was always `None` and
  the image add-sites could not be tagged. Backward-compatible (callers that ignored the return still work).
- **Test** (`tests/test_lineage_recording.py`, integration): the real `run_pre_process_image` path stamps
  `op='preprocess'` (source='pipeline') + a `derived_from` edge to the input; the shared add-labels mechanism
  records op + edge on a labels layer. Remaining add-sites (VPT bead detection, brightfield/in-vitro condensate
  masks — which need dropdown-layer re-resolution or branch-dependent op selection) are a later C1 increment.

## [1.6.356] - 2026-07-25
### Fixed — **Contact angle was capped at 90° — every hydrophobic droplet was reported as its acute supplement (Outstanding-Work spec B3).**
`estimate_contact_angle` (`toolbox/invitro/analysis.py`) computed `θ = arcsin(a/R)`, which can never exceed 90°,
so a droplet with a true contact angle of 130° (a case the docstring explicitly claimed reachable) came back as
~50°. Two compounding causes: `arcsin` is bounded at 90°, **and** the base line was taken as the *widest*
boundary row — which for θ>90° is the equator, not the contact line, so `a/R` saturated at 1.

- The contact line is now the **bottom-most boundary row** (where the droplet meets the glass), not the widest
  row. For θ≤90° these coincide, so hydrophilic results are unchanged; for θ>90° this finds the true (narrower)
  contact line instead of the equator.
- The angle is computed from the circle geometry directly: `θ = arccos((cy − base_row)/R)`, which spans the
  full **0–180°**. A hydrophobic droplet (fitted centre above the contact line, `cy < base_row`) now reports
  its true obtuse angle. Docstring corrected. (While verifying, the sign convention in the original spec sketch
  was found to be inverted — confirmed empirically against rasterized known-angle droplets before shipping.)
- **Golden-master test** (`tests/test_contact_angle.py`): spherical caps of known contact angle 40–135° are
  recovered within 4°; the θ=130° smoking-gun is not folded to ~50°. The old code fails both.

## [1.6.355] - 2026-07-25
### Fixed — **The molecular-counting GUI reinstated the R² selection-effect bug the library had already fixed (Outstanding-Work spec B2).**
`count_molecules_pooled` defaults to `r2_min=0.0` (keep all traces) with a documented danger note that a 0.999
gate inflated a true mean of 44 to 77 by selecting for bright traces (better SNR → better fit). But the widget
builder overrode the spinbox back to **0.999**, so through the GUI the selection effect was live.

- `_build_molecular_counting_panel` (`toolbox/molecular_counting_tools.py`) now defaults the R² spinbox to
  **0.0** (step 0.05), with a tooltip that states the selection effect explicitly. The spinbox stays present and
  adjustable — an expert may still gate a specific dataset; the fix is the default and the disclosure.
- `_on_run` now surfaces a one-line warning when a raised gate drops more than 10% of traces ("N of M traces
  excluded by the R²>… gate; excluded traces skew low-copy-number, biasing the pooled mean high"), so a raised
  gate is never silent.
- **Test** (`tests/test_molecular_counting_ui_default.py`): the panel's R² spinbox starts at 0.0 and its tooltip
  states the bias. Integration-marked (needs Qt).

## [1.6.354] - 2026-07-25
### Fixed — **Kaplan–Meier survival mishandled censoring three ways (Outstanding-Work spec B1).**
`kaplan_meier_lifetimes` (`toolbox/condensate_physics/survival.py`) claimed to handle left-censoring but only
computed right-censoring, reported the mean as the naive `durations.mean()`, and left a residual risk-set bug.

- **Risk set (residual bug found while implementing B1).** `n_at_risk` is now `sum(durations >= t)` — the
  canonical form. The old decrement only subtracted censored tracks whose duration *exactly* equalled an event
  time, so a censored track that dropped out **between** event times was never removed, overcounting the risk
  set and biasing survival **high** (verified: `n_at_risk` read 3 where the correct value is 2).
- **Left-censoring now real.** Frame-0-born tracks (unknown true birth) are treated as **censored** — they enter
  the risk set but are never counted as an observed death (their duration is only a *minimum* lifetime). The
  docstring now states exactly this instead of claiming a full left-censored estimator that was not computed.
- **Censored-aware mean via RMST.** `mean_lifetime_frames` is now the restricted mean survival time (area under
  the KM curve) instead of `durations.mean()`, which counted right-censored (incomplete) durations as complete
  deaths and biased the mean **low**. `attrs['mean_lifetime_is_rmst'] = True` flags it; `n_left_censored` /
  `n_right_censored` are surfaced so the user sees how much of the population was censored. Median unchanged.
- **Golden-master test** (`tests/test_kaplan_meier.py`): risk set equals `sum(durations >= t)` including
  between-event censoring; RMST > naive mean under heavy right-censoring; frame-0 tracks create no survival
  step-down (not events); empty input → empty frame.

## [1.6.353] - 2026-07-25
### Fixed — **Costes M1/M2 and the threshold search were both wrong — thresholded colocalization was meaningless (Outstanding-Work specs A3 + A4, shipped together).**
The Manders thresholded coefficients and the Costes threshold that feeds them are only meaningful together, so both are fixed in one version.

- **A3 — M1/M2 now cross-reference the channels** (`toolbox/coloc/analysis.py`). M1 is the fraction of
  channel-1 intensity in pixels where **channel 2** is above its threshold (and the mirror for M2),
  ROI-masked, with a `nan` guard when a denominator is zero or the threshold is undefined. The old code
  summed `image1` where `image1 > thresh1` — self-referential, hence ~1 for any threshold at the noise
  floor, so two completely disjoint channels reported M1 ≈ M2 ≈ 1 (false colocalization).
- **A4 — `costes_thresholding` rewritten** (`toolbox/coloc/thresholding.py`). Three compounding defects
  fixed: (a) the intensity line is now **orthogonal / total-least-squares** (PCA principal component,
  extracted as `_costes_tls_line`) instead of OLS `curve_fit`, which was biased low under symmetric
  errors-in-variables noise; (b) the threshold descends across the **full intensity range** (256 levels)
  instead of fixed `-0.01` steps capped at 50 iterations, which on a uint16 image never left the maximum;
  (c) it stops on the correct Costes criterion (`r_below ≤ 0`) instead of `|r| > 0.1`. Degenerate inputs
  (empty / all-zero / non-positively-correlated) return `(nan, nan)` rather than a fabricated ~max
  threshold. `costes_linear_model` is kept for external callers.
- **Golden-master tests.** `tests/test_costes_threshold.py`: uint16 threshold lands near the noise/signal
  boundary (not ~max); TLS slope recovered within 10% under errors-in-variables noise (OLS under-estimates);
  degenerate inputs → nan. `tests/test_costes_manders.py` (drives the real dispatch): fully colocalized →
  M1,M2 ≈ 1; disjoint → not the old false ~1; partial overlap → a graded value strictly in (0, 1).

## [1.6.352] - 2026-07-25
### Fixed — **Nematic order parameter measured x-axis alignment, not the director — a perfectly aligned bundle read as isotropic (Outstanding-Work spec A2).**
`orientation_order_parameter` (`toolbox/morphological_complexity_tools.py`) returned `S = |⟨cos2θ⟩|`, which is
referenced to the image x-axis. A bundle of objects all oriented at 45° gives `cos(90°) = 0`, so it reported
**S = 0 (isotropic)** for a perfectly aligned bundle.

- `S` is now the director-invariant resultant magnitude `S = |⟨exp(2iθ)⟩| = √(⟨cos2θ⟩² + ⟨sin2θ⟩²)` — the
  magnitude of `mean_resultant`, which the code already computed one line below but discarded. `circular_variance`
  is now exactly `1 − S`; `preferred_angle_deg` unchanged. Docstring corrected. The per-object bbox/brushing
  contract and the empty-df guard are untouched.
- **Golden-master test** (`tests/test_nematic_order.py`): elongated ellipses all at 45° → `S > 0.95` (old code
  ~0); crossed 0°/90° → `S < 0.2`; uniform-random orientations → `S < 0.25`; `circular_variance == 1 − S`.

## [1.6.351] - 2026-07-25
### Fixed — **Autocorrelation used the wrong transform — every ACF-derived object size was distorted (Outstanding-Work spec A1).**
`calculate_autocorrelation` (`toolbox/correlation_func_analysis_tools.py`) claimed the Wiener–Khinchin identity
(ACF = inverse-FFT of the power spectrum `|F|²`) but computed `np.real(fft2(image))**2` — the square of the
**real part**, which discards the imaginary component and never subtracts the DC term. That is not the power
spectrum: it is phase-blind and background-dominated, so the recovered autocorrelation width — and thus **every
ACF-derived diameter in the spatial-ACF module** (`autocorrelation_analysis` → `run_autocorrelation_analysis`,
and `spatial_acf_tools.sacf_single_roi`) — was wrong.

- Body replaced with the true form: subtract the mean, then `acf = Re(ifft(F * conj(F)))` fftshifted to centre,
  normalised to `[0, 1]` (signature and return shape unchanged, so both callers still slice/unpack identically).
- The correct form already existed in-repo (`spatial_randomness_tools.autocorrelation_length`); the two ACF
  paths now agree (closes a consistency defect).
- **Golden-master test** (`tests/test_autocorrelation_fix.py`): (1) the ACF of a Gaussian blob of width σ is a
  Gaussian of width `σ√2` — recovered within 5% by a fit; (2) the ACF is translation-invariant (a centred blob
  and a shifted blob give the identical ACF — the old `Re(F)²` code was phase-sensitive and failed this); (3)
  the 1/e width agrees with the independent `autocorrelation_length`. The old code fails all three.

## [1.6.350] - 2026-07-25
### Changed — **`data_qc_tools.py` is now a thin shim — the decomposition is complete (data_qc_decomposition, DONE).**
The coupled optical/stability checks, the biological check, and the orchestrator move out; the 1,949-line module
becomes a 25-line re-export shim. Checks now live by family in `toolbox/data_qc/`.

- **`data_qc/illumination.py`** — `qc_vignetting`, `qc_ghosting` (flat-field + stray-light field artifacts).
- **`data_qc/aberration.py`** — `qc_spherical_aberration` (+ nested `_axial_sharp`), `qc_chromatic`.
- **`data_qc/stability.py`** — `qc_photobleaching`, `qc_drift` (+ `_feature_scale`), `qc_vibration` (+ `_conc`).
- **`data_qc/biological.py`** — `qc_biological_objects` (surfaced separately from the acquisition checks).
- **`data_qc/runner.py`** — `run_full_qc`, importing each check from its family module.
- `_shift_normalise` (the registration-shift normaliser shared by drift / vibration / chromatic) went to
  `data_qc/_base.py` — the one genuine cross-family primitive.
- Every function moved **VERBATIM** — no re-tuned thresholds. `data_qc_tools.py` re-exports the full historical
  surface (`1,125 → 25 lines`), so the QC dashboard, gallery, navigator quality-gate and reliability index are
  unchanged. Cry-wolf holds; every verdict **and** did-it-run flag pinned by the QC net; full
  `pytest -m "core or base"` green. **`data_qc_tools` decomposition complete.**

## [1.6.349] - 2026-07-25
### Changed — **Four self-contained QC check families split out (data_qc_decomposition, step 2b).**
The metric checks are genuinely independent, so each family moves to its own module — the low-risk part of the split.

- **`data_qc/exposure.py`** — `qc_saturation` (saturation / clipping / dynamic-range).
- **`data_qc/focus.py`** — `edge_width_px`, `diffraction_limit_px`, `qc_focus`, `_qc_focus_stack` (+ its nested
  `_sharp`), `_qc_focus_absolute`.
- **`data_qc/noise.py`** — `qc_snr` (SNR vs a MAD noise floor).
- **`data_qc/sampling.py`** — `qc_nyquist`, `qc_time_sampling`.
- Every function moved **VERBATIM** — no re-tuned thresholds, no "improved" metrics (this file's history
  includes an inverted spherical-aberration check; a small change flips a verdict silently). Each family imports
  only the `_base` helpers it needs. `data_qc_tools.py` re-exports all of them, so `run_full_qc` and the
  dashboard / gallery / quality-gate / reliability-index callers are unchanged; it drops 1,532 → 1,125 lines.
- Cry-wolf holds (clean data raises zero flags); each check's verdict **and** did-it-run flag are pinned by the
  QC net. Full `pytest -m "core or base"` green. Remaining: illumination, aberration, stability + the runner.

## [1.6.348] - 2026-07-25
### Changed — **Shared QC primitives extracted to `data_qc/_base.py` — the foundation for the check-family split (data_qc_decomposition, step 2a).**
The acquisition-QC check families share low-level helpers, so — before the families can move — those primitives
get a shared home (the same pattern the image-processing decomposition used).

- `_to_float` / `_dtype_max` (dtype-aware normalisation), `_robust_noise_std` (MAD noise estimate),
  `_mean_frame` (stack → representative frame), and `_not_applicable` (the standard skipped-check result) moved
  **VERBATIM** to `toolbox/data_qc/_base.py`. Self-contained (only numpy + debug_log; inter-calls stay within
  the group).
- `data_qc_tools.py` imports them from `_base` (so its checks resolve) and re-exports them (callers unchanged);
  it drops 1,633 → 1,532 lines. No number changed — pinned by the QC net. Recorded in `_DELIBERATE`. Full
  `pytest -m "core or base"` green. Next: the check families (exposure, focus, noise, …) import from `_base`.

## [1.6.347] - 2026-07-24
### Changed — **The QC report split out of `data_qc_tools.py` into a `data_qc/` package (data_qc_decomposition, step 1 — report).**
Starting the by-family decomposition of the 1,949-line acquisition-QC module with the biggest, zero-science-risk
piece: the 309-line `plot_qc_report` (the multi-panel QC summary render — per-check status rows, focus/exposure
panels, the sampling gauge) and its private status-colour/label dicts moved **VERBATIM** to
`toolbox/data_qc/report.py`.

- Presentation only — no science. `data_qc_tools.py` re-exports `plot_qc_report`, so every caller is unchanged;
  it drops 1,949 → 1,633 lines. Pinned by the QC test net (`test_data_qc`, `test_qc_gallery`,
  `test_qc_ui_contract`, `test_biological_qc_surfaced` — all pass through the re-export). Recorded in
  `_DELIBERATE`. Full `pytest -m "core or base"` green. Remaining steps: the check families (exposure, focus,
  noise, illumination, aberration, stability, sampling) and the runner.

## [1.6.346] - 2026-07-24
### Changed — **Object-based colocalization moved into the `coloc/` package; the colocalization decomposition is complete (coloc_decomposition, step 6 — object_based).**
The sibling `obj_based_coloc_analysis_tools.py` (per-object Manders/Jaccard/Dice/centroid-distance
colocalization and its Qt method-picker dialog) moved **VERBATIM** to `toolbox/coloc/object_based.py`.

- The file is one cohesive domain and self-contained (no pixel_wise/coloc dependency), so it moved whole.
  `obj_based_coloc_analysis_tools.py` is now a **pure re-export shim (1,216 → 21 lines)** — its 15 public
  functions (`run_obca`, `object_based_colocalization_analysis`, `run_manders_coloc`, …) resolve through it, so
  every caller (the navigator, `two_channel_coloc_tools`, `ui_analysis_mixin`, `ui_modules`) is unchanged.
- No number changed — pinned by the coloc test net; imports headless (`test_headless_science` passes).
  Recorded in `_DELIBERATE`. Full `pytest -m "core or base"` green. **The colocalization science is now fully
  decomposed into `coloc/` (metrics, thresholding, nulls, temporal, analysis, object_based); both old files
  are thin shims.** coloc_decomposition spec complete.

## [1.6.345] - 2026-07-24
### Changed — **Colocalization orchestration split out; `pixel_wise_corr_analysis_tools.py` is now a 33-line re-export shim (coloc_decomposition, step 5 — analysis).**
The final split for this file. The orchestration (`pixel_wise_correlation_analysis`, `process_pwcca_methods`,
`run_pwcca`), the visualizers (`li_ica_histogram`, `li_ica_plot`, `cytofluorogram_plot`,
`cross_correlation_matrix`), and the `pwcaDialog` Qt dialog (with its `LAST_COLOC_DIAGNOSTICS` state) moved
**VERBATIM** to `toolbox/coloc/analysis.py`, which imports the coefficients / thresholding / null tests it runs
from the sibling `coloc` modules (a clean layered dependency, no shim cycle).

- `pixel_wise_corr_analysis_tools.py` is now a **pure re-export shim (2,029 → 33 lines)**: every public name
  (from `pearsons_correlation` to `run_pwcca` to `pwcaDialog`) resolves through it, so every caller — the coloc
  UI, `two_channel_coloc_tools`, `ui_analysis_mixin`, `ui_modules` — is unchanged.
- `analysis.py` keeps the `_NoQt` headless fallback, so it still imports with no GUI (`test_headless_science`
  passes). No number changed — pinned by the full coloc test net through the re-exports. Recorded in
  `_DELIBERATE`. Full `pytest -m "core or base"` green. The colocalization file is fully decomposed into
  `coloc/` (metrics, thresholding, nulls, temporal, analysis); the sibling `obj_based_coloc_analysis_tools.py`
  is the remaining coloc target.

## [1.6.344] - 2026-07-24
### Changed — **Colocalization-over-time split into `coloc/temporal.py` (coloc_decomposition, step 4).**
- `coloc_time_trace` (per-frame coefficient trace over a two-channel stack → tidy DataFrame) and its two
  renderers (`plot_per_cell_coloc_time_trace`, `plot_coloc_time_trace`, each with a nested `_on_pick` brush
  handler) moved **VERBATIM** to `toolbox/coloc/temporal.py`.
- `coloc_time_trace` runs every coloc coefficient per frame, so temporal now imports them from `coloc.metrics`
  — a clean layered dependency. To enable that, `li_intensity_correlation` (a standalone raw measure that was
  deferred in step 1) was moved into `coloc/metrics.py` where it belongs, so temporal imports it there rather
  than through the shim (which would cycle).
- No number changed — pinned by the coloc test net through the re-exports. `pixel_wise_corr_analysis_tools.py`
  drops 1,192 → 907 lines (from 2,029 across the four steps). Recorded in `_DELIBERATE`. Full
  `pytest -m "core or base"` green. Remaining coloc steps: analysis (orchestration), object_based.

## [1.6.343] - 2026-07-24
### Changed — **Colocalization spatial-null machinery split into `coloc/nulls.py` (coloc_decomposition, step 3).**
The significance layer — `spatial_null_test` (does the coefficient beat a **structure-preserving** null, so two
blurred-but-independent channels don't read as colocalized?), its `_block_shuffle` / `spatial_correlation_length`
randomisation, the `scramble_*` randomisers, and `perform_costes_test` — moved **VERBATIM** to
`toolbox/coloc/nulls.py`.

- Self-contained: every inter-call stays within the group (`spatial_null_test`'s nested `_coef` computes the
  correlation inline via scipy, so nulls doesn't even depend on the metrics module). No number changed —
  pinned identical by the spatial-null test net (the gate that stops the false-positive), which passes through
  the re-export.
- `pixel_wise_corr_analysis_tools.py` re-exports the seven names and drops 1,545 → 1,192 lines (from 2,029
  across the three steps). Recorded in `_DELIBERATE`. Full `pytest -m "core or base"` green. Remaining coloc
  steps: temporal, analysis, object_based.

## [1.6.342] - 2026-07-24
### Changed — **Colocalization thresholding split into `coloc/thresholding.py` (coloc_decomposition, step 2).**
- `costes_thresholding` (+ its `costes_linear_model` regression) — the Costes intensity-threshold
  determination — and `manders_threshold_sensitivity` (the M1/M2-across-thresholds stability sweep) moved
  **VERBATIM** to `toolbox/coloc/thresholding.py`. Self-contained (the nested `_base` moves with the sensitivity
  sweep; `costes_thresholding` calls only its own `costes_linear_model`).
- Classification note: `perform_costes_test` was **left** — it calls the randomisation machinery
  (`_block_shuffle`, `spatial_correlation_length`), so it belongs with the nulls step, not thresholding.
- Moved with no number changed; pinned by the coloc test net (passes through the re-export).
  `pixel_wise_corr_analysis_tools.py` re-exports the three names and drops 1,738 → 1,545 lines. Recorded in
  `_DELIBERATE`. Full `pytest -m "core or base"` green. Remaining coloc steps: nulls, temporal, analysis,
  object_based.

## [1.6.341] - 2026-07-24
### Changed — **Colocalization raw metrics split into a `coloc/` package (coloc_decomposition, step 1 — metrics).**
The 2,029-line `pixel_wise_corr_analysis_tools.py` mixes the raw measures, thresholding, spatial nulls, the
temporal trace, and the orchestration. This starts a domain split with the cleanest layer.

- New `toolbox/coloc/` package; `coloc/metrics.py` holds the **raw pairwise correlation/overlap measures** —
  Pearson (`pearsons_correlation`), the Manders overlap + M1/M2 coefficients, and the rank correlations
  (Spearman, Kendall, weighted-tau). Each takes two images + an ROI mask and returns a scalar; none depends on
  another coloc function, so the move is clean.
- Moved **VERBATIM** — no number changed; pinned identical by the colocalization test net (`test_coloc_metrics`,
  `test_pixel_coloc`, `test_group_f_coloc`), which passes unmodified through the re-export.
  `pixel_wise_corr_analysis_tools.py` re-exports the seven names, so every caller is unchanged; it drops
  2,029 → 1,738 lines. Recorded in `_DELIBERATE`. Full `pytest -m "core or base"` green. Remaining coloc steps:
  thresholding, nulls, temporal, analysis, object_based.

## [1.6.340] - 2026-07-24
### Changed — **The per-file load helpers moved to their own module; the file_io.py decomposition's four concerns are now extracted (file_io_decomposition, step: loading).**
`_add_image_or_mask_single` (image-or-mask? load it) and `_open_image_auto_single` (2D-or-stack? route it) —
the per-file workhorses the public `add_image_or_mask` / `open_image_auto` entry points call once per file.

- Moved **VERBATIM** to a `_LoadingMixin` in new `file_io/loading.py`; `FileIOClass` inherits it, so the public
  entry points (which stay in file_io as orchestration) call `self._..._single(...)` unchanged. No Qt — these
  route to `open_image` and delegate the pixel/metadata work to the format openers and `open_2d_image`, whose
  fire order (pixel-size gate → provenance → tags → sidecar → channel identity) is untouched.
- Load path verified: the scene / session-threading / CZI / one-plane / axis / naming tests pass. `file_io.py`
  drops **1,231 → 1,061 lines — 601 fewer than the 1,662 it started at**, across the four extraction steps
  (progress, dialogs, session_actions, loading). Recorded in `_DELIBERATE`.
- Full `pytest -m "core or base"` green. file_io.py now holds the public entry points + orchestration + the
  module-level naming helpers; the four Qt/progress/session/loading concerns each live in a focused module.
  (The PyQt5-import drop remains a separate, larger effort — the file's file-pickers/message-boxes are the bulk
  of its remaining Qt use.)

## [1.6.339] - 2026-07-24
### Changed — **Save-and-Clear moved out of the file loader to its own session-action module (file_io_decomposition, step: session_actions).**
`save_and_clear_all` (182 lines — the Save-and-Clear dialog, the session write, and the repository/viewer
clear) is a session action, not file I/O.

- Moved **VERBATIM** to a `_SessionActionsMixin` in new `file_io/session_actions.py`; `FileIOClass` inherits it,
  so `file_io.save_and_clear_all(viewer)` (called from the menu and `ui_modules`) is unchanged. It is Qt-bound
  (a save dialog), so it lives in its own module rather than the Qt-free `session.py`.
- Save path unchanged: the session-write / manifest / no-silent-swallow tests pass. `file_io.py` drops 1,412 →
  1,231 lines (down from 1,662 across the three extraction steps). The move is recorded in `_DELIBERATE`.
- Full `pytest -m "core or base"` green. Remaining file_io step: `loading.py` (per-file helpers), preserving
  the load contract exactly.

## [1.6.338] - 2026-07-24
### Changed — **The channel-naming dialog flow moved out of the file loader (file_io_decomposition, step: dialogs).**
`assign_channels_in_dialog` (120 lines — the confidence gate, the naming dialog, and the recall/remember of
channel identities) and its `_channels_all_confident` helper moved out of `file_io.py`.

- Moved **VERBATIM** to a `_DialogsMixin` in `file_io/dialogs.py`, co-located with the `ChannelAssignmentDialog`
  it drives; `FileIOClass` inherits it, so `self.assign_channels_in_dialog(...)` is unchanged. `derive_layer_name`
  deliberately stays in file_io (two tests AST-parse it there) and is lazy-imported in the moved method to avoid
  a cycle — the pattern the sibling dialog code already uses.
- Load path unchanged: the naming/scene/session/CZI load tests pass. `file_io.py` drops 1,548 → 1,412 lines.
- **PyQt5-import status (the step's question): still NOT droppable.** Even with `assign_channels_in_dialog` and
  `_run_with_busy_progress` gone, file_io.py uses Qt pervasively — `QFileDialog` ×14 (the file pickers),
  `QMessageBox` ×10 (load prompts), and other dialogs. It is a Qt-bound load controller; the "no GUI import"
  win would require extracting all of those, not just these two. Recorded honestly, not claimed.

## [1.6.337] - 2026-07-24
### Changed — **The file loader's busy/progress plumbing moved to its own module (file_io_decomposition, step: progress).**
`_run_with_busy_progress` (the modal off-thread busy dialog the one-time CZI/IMS frame-index parse runs behind)
was 113 lines of Qt plumbing inside the 1,662-line `file_io.py`.

- Extracted **VERBATIM** to new `file_io/progress.py` as a `_ProgressMixin`; `FileIOClass` now inherits it, so
  every `self._run_with_busy_progress(...)` call site (in the stack openers) is unchanged. The one class-level
  reference (`FileIOClass._orphan_load_threads`, the cancel-orphan list) became `type(self)._orphan_load_threads`
  — the same concrete class, mixin-safe, avoiding a circular import. All Qt imports stay function-local, so the
  new module imports headless.
- Load path unchanged: the CZI/IMS streaming tests (which drive this path) pass. `file_io.py` drops to 1,548
  lines. Full `pytest -m "core or base"` green.
- **PyQt5-import status (the step's question):** file_io.py still imports PyQt5 at module scope (lines 52–53) —
  the dialog-building methods use it — so the architectural "no GUI import" win needs the `dialogs.py`
  extraction (the next step), not this one. Recorded, not claimed.

## [1.6.336] - 2026-07-24
### Changed — **Condensate wetting physics moved out of the masking module to its rightful home (label_mask_split, step 1 — the physics relocation).**
`neck_geometry` and `fit_elastocapillary_length` are fusion/coalescence measurements — neck radius, dihedral
angle, elastocapillary length, the lobe-residual elasticity signature, and the size-transition fit — that could
reach a Methods section. They lived in `label_and_mask_tools.py`, where nobody looking for the fusion physics
(or writing it up) would find them.

- Moved **VERBATIM** to new `toolbox/condensate_physics/wetting.py`, beside the rest of the material-state work
  (moduli, MSD, relaxation, survival, coarsening…). No number changed — pinned identical by
  `tests/test_group_c_geometry.py` (the neck-geometry sphere relation and the elastocapillary fit), which
  passes unmodified through the re-export.
- `label_and_mask_tools.py` re-exports both public names, so every existing caller (`lm.neck_geometry`,
  `lm.fit_elastocapillary_length`) is unchanged; the masking module drops ~290 lines of unrelated physics.
- The `_DELIBERATE` move is recorded in `test_no_FUNCTION_has_vanished` with its reason. Full
  `pytest -m "core or base"` green. Remaining label_mask_split steps: split the masking half into
  `masks/morphology.py` · `splitting.py` · `measurement.py` (separate commits).

## [1.6.335] - 2026-07-24
### Added — **Navigator execution-adapter layer, Phase 3 (cont.): cell analysis — the step that measures what segmentation produced, chained.**
The real cell chain a guided run computes is *segment, then measure*. 1.6.334 shipped the segmentation adapter;
this adds the measurement step, so the two now run end to end.
- **New route-proven workflow: `feature_analysis_tools` (cell target) → the `cell_analysis` batch step.** Both the
  batch route and a direct call reduce to the same `cell_analysis_func`, so it is proven **without torch** on a
  synthetic labelled mask (cell analysis measures any integer label image). `tests/navigator/test_navigator_cell_analysis_adapter.py`
  (`base`, 4): guided == manual, **bit for bit**, over the per-cell feature table.
- **The chain contract.** `cell_analysis` reads the `cellpose_mask` the segmentation adapter writes into the shared
  run state — so in a real cell plan the guided run now fires `segmentation_tools → feature_analysis_tools` end to
  end (asserted: a real cell/morphology plan resolves BOTH steps). With no upstream mask the step is reported as an
  error (segmentation must run first), never a silent empty result.
- **Coarse-module honesty holds.** `feature_analysis_tools` resolves to `None` for a condensate target — its
  `condensate_analysis` route depends on the unproven condensate-segmentation route — so it is reported, never
  guessed. Cell analysis surfaces no new run-time knob (it uses the session measurements already reviewed on
  segmentation), so the parameter review stays clean.
- **Later** (per the `navigator_execution_adapters` spec): more workflows one at a time, then dock progress +
  cancel (Phase 4).

## [1.6.334] - 2026-07-24
### Fixed + Added — **Navigator execution-adapter layer, Phase 3: adapters fire on REAL plan steps; cell segmentation is the next route-proven workflow.**
**The finding this closes:** Phase 1 proved the executor mechanism against a *synthetic* step name
(`background_removal`), but real compiled plans emit toolbox-**module** step names (`segmentation_tools`,
`image_processing_tools`, …) — so that adapter never fired in production: every real plan step reported
"run from your panel". Phase 3 re-keys the adapters on the real module names and adds the next proven workflow.
- **Adapters now key on the real navigator module names** `execution_order` reports. `image_processing_tools`
  (the module whose product is a background-corrected field) maps to the proven `background_removal` batch step
  — Phase 1's bit-for-bit proof is now *live* on real plans.
- **New route-proven workflow: single-frame cell segmentation.** `segmentation_tools` for a **cell** target maps
  to `cellpose_segmentation` — the batch step `test_route_equivalence` proves computes identically to the manual
  route. A **coarse** module resolves its batch step from the intent: condensate segmentation resolves to `None`
  (its batch route is an unproven documented gap) and is reported, never guessed; time-series cell segmentation
  (`ts_cellpose_tools`, keyframe propagation) is deliberately unmapped — a single-frame stand-in would be wrong
  science. `resolve_batch_step(step, intent)` is the single "will this step run" authority, shared by the
  executor and the parameter review.
- **Reviewed parameters reach the handler where it actually reads them.** Cellpose reads `cell_diameter` from the
  `data_instance`, not the params dict — so `params_from(intent, ctx, state, reviewed)` now receives the reviewed
  values and applies each where the handler reads it (the params dict for `ball_radius`, the `data_instance` for
  `cell_diameter`); the executor never guesses.
- **Proof** (`tests/navigator/test_navigator_cellpose_adapter.py`, `base`, 5): a real cell plan's segmentation
  step resolves to a batch handler (the regression guard for the synthetic-name gap); driving
  `cellpose_segmentation` through the adapter with a reviewed diameter equals a direct Cellpose call at that
  diameter, **bit for bit**; condensate/time-series segmentation run nothing and are reported; and every batch
  step an adapter targets is a real registered handler.
- **Later** (per the `navigator_execution_adapters` spec): more adapters one workflow at a time, then dock
  progress + cancel (Phase 4).

## [1.6.333] - 2026-07-24
### Added — **Navigator execution-adapter layer, Phase 2: an honest parameter review — seeded, editable, provenance-recorded.**
The navigator asks *what* to do, not *with which parameters* — but a guided run that silently picks a
rolling-ball radius (or a segmentation method) the user never saw is worse than asking. Phase 2 adds the
minimal parameter review, Qt-free at its core:
- New **`navigator/parameters.py`**: each adapter-covered step declares its **material** parameters (the handful
  the science turns on, not every argument). `build_param_review(plan, ctx)` seeds them by a declared
  precedence — **a matching preset → a session value → the grounded function default** — never an invented one.
  `ReviewedStep` generalises the preset `populate-but-never-lock` contract to the no-preset case too: the user
  may edit any value, and the edit is tracked as **provenance in the exact `PresetApplication.record()` shape**
  (`preset_key`, `modified_parameters`, `is_modified`, `parameters`), `preset_key=None` when no preset seeded it.
- **The reviewed values drive the run.** `run_plan(…, params_by_step=, provenance_by_step=)` merges the reviewed
  values over the adapter's derived params (routing keys like `active_layer` still come from the adapter) and
  records the provenance onto each ran step's `StepOutcome`. `tests/navigator/test_navigator_parameters.py`
  (`base`, 9) pins it: an edited rolling-ball radius makes the guided result equal the **manual operation at
  that radius** — and *not* at the default — so the edit provably reaches the computation.
- **The dock surfaces it**: the plan view renders an editable "Review parameters" form (a spin/combo per
  material parameter, seeded, tooltip-documented) above Run; the Run passes the `ParamReview` through
  `run_plan_via_central_manager`, which also reports which steps ran with modified parameters. A programmatic
  run with no form still builds a default-seeded review, so provenance is recorded either way.
- **Later phases** (per the `navigator_execution_adapters` spec): more adapters one workflow at a time (each
  behind its route-equivalence test), then dock progress + cancel.

## [1.6.332] - 2026-07-24
### Added — **Navigator execution-adapter layer, Phase 1: "Run analysis" computes a plan through the batch handlers, gate-respecting (guided == batch == manual).**
`selection_scale` Part 2 established there is no uniform "run this op" — but the batch `_STEP_MAP` handlers ARE
the proven "same computation" route (uniform `(state, image_path, params, output_dir)`; `test_route_equivalence`
asserts batch == manual). This ships the executor that drives them:
- New Qt-free **`navigator/executor.py`**: `run_plan(plan, state, …)` executes a compiled plan by driving the
  batch handlers in the gate-respecting order (`execution.execution_order`), threading each step's output
  through a shared `state` as a batch replay does. An `ExecAdapter` maps a plan step to a batch handler and
  derives its params; **a step with no adapter is reported ('needs_panel'), never invoked with guessed
  arguments** — the whole point of an adapter layer over a generic `fn(image)` (which would produce wrong
  science silently). Gate semantics are READ from `execution_order`: a blocker halts the run (state untouched),
  a caveat runs with its reason, probes first.
- **One proven adapter (`background_removal`)** — the shortest chain `test_route_equivalence` covers — so the
  acceptance gate is pinned: `tests/navigator/test_navigator_executor.py` (`base`, 5) asserts **guided == batch
  == manual, bit for bit**, plus a blocker halts + leaves state untouched, a caveat runs, and an uncovered step
  is reported not invoked.
- **The Run button is wired** (`run_plan_via_central_manager`, passed as `on_run` from `central_manager`):
  adapter-covered steps run through the batch handlers; uncovered steps are reported "run these from their
  method panels, in order". Safe on an uncovered plan (nothing runs); best-effort.
- **Later phases** (per the `navigator_execution_adapters` spec): a parameter-review panel, more adapters one
  workflow at a time (each behind its own route-equivalence test), and dock progress/cancel.

## [1.6.331] - 2026-07-24
### Added — **Save an answered guided plan as a reusable method template (selection_scale Part 3).**
The navigator's payoff: a plan the user likes should be reusable on other data without re-answering. A guided
template IS a preset with provenance about how it was authored — so it follows the `analysis_presets`
discipline (it records the ANSWERS that produced it) but, being a user-level artefact that survives sessions,
lives in the general `user_settings` service rather than the hardcoded, sensitivity-validated preset registry.
- New Qt-free **`navigator/templates.py`**: `GuidedTemplate` (name, observables, target, the question trail,
  step names, parameters) + `template_from_plan` / `save_template` / `list_templates` / `load_template` /
  `delete_template` / `rename_template` (never overwrites), persisted under `user_settings` key
  `navigator.templates`.
- **The verdicts are deliberately NOT stored.** A quality-gate verdict is a property of the DATA, not the
  plan — a template runnable on one dataset may be blocked on another — so applying a template
  (`intent_from_template` → recompile against the new context) **re-evaluates every gate**. Carrying a verdict
  over would assert quality never checked on this data. A corrupt template entry degrades to "not available"
  (skipped), never a crash.
- **Reachable**: a '💾 Save as template…' button in the navigator plan view persists the current answers under
  a user-supplied name.
- Tests: `tests/navigator/test_navigator_templates.py` (`base`, 7 — save+reload carries steps and answers,
  applying to a different dataset re-evaluates the gates, verdicts absent, persistence across sessions,
  delete/rename-never-overwrites, corrupt-degrades) + a dock integration test for the Save button. Full
  core-or-base green.
- **Follow-on:** the template-picker UI (start a new analysis FROM a saved template — the apply half's UI; the
  mechanism is done and tested).

## [1.6.330] - 2026-07-24
### Added — **Gate-respecting execution model for a guided plan, and an actionable run order in the dock (selection_scale Part 2).**
Part 2 asked to wire the navigator's Run button to execute the compiled plan "through the existing execution
path — the same one each method panel uses". Verified against the tree, that path **does not exist uniformly**:
PyCAT's operations have bespoke, panel-collected signatures (e.g. `segment_subcellular_objects(original_image,
pre_processed_image, cell_mask, cell_label, ball_radius, …15 params)`) threaded from prior steps and tuned by
the user, and `resolve_operation`'s callable is invoked nowhere — there is no generic "run this op". So full
auto-execution is a **per-operation adapter layer** (input marshalling + a parameter story), not a mechanical
wiring, and invoking those functions with guessed arguments would risk wrong science. This ships the safe,
correct **foundation** that layer needs, plus the honest, improved dock:
- New Qt-free **`navigator/execution.py`**: `execution_order(plan)` turns a compiled `Plan` into the sequence a
  runner (or a guided hand-off to the real panels) must honour — **QC probes first**, a **blocker halts the
  run** at that step (it and everything after are `skipped`) with the stated reason, a **soft caveat runs**
  with its reason carried. Plus `is_runnable`, `first_blocker`, and `execution_briefing`.
- **The dock is now actionable, not a dead control**: the "auto-run is coming" message carries the
  **gate-respecting run order** (`probe → segment → measure …`) so the user runs the steps in their method
  panels in exactly the right sequence — the "guided route IS the manual route" the spec wants, made explicit.
- Tests: `tests/navigator/test_navigator_execution.py` (`base`, 6 — probes lead, a blocker halts + skips the
  rest, a caveat runs, a clean plan runs every step, the briefing names order + blocker, frozen records) and a
  dock test that the run-order note is produced. Full core-or-base green.
- **Deferred (architectural finding, recorded on the spec):** the per-operation execution adapters that let
  the Run button compute each step end to end — a larger layer, since there is no uniform op invocation. Part
  3 (saveable templates) also remains.

## [1.6.329] - 2026-07-24
### Fixed — **Brushing highlights and zooms to the right place on calibrated & upscaled layers (selection_scale_and_guided_templates Part 1).**
Reported: the cellular-object plot "isn't brushing properly — it doesn't highlight the cell or zoom to it".
Cause: the selection overlay was drawn at the default scale 1.0 while its bbox is in the **source layer's
pixel grid**, and the camera (which works in WORLD coordinates) centred on the raw pixel index. So on any
layer with a non-unit `scale` — which every **calibrated** layer has (`layer.scale = pixel_size`), and every
**upscaled** layer — the box and the camera both landed in the wrong place. It is one missing transform, which
is why "doesn't highlight" and "doesn't zoom" appeared together.
- `show_selection` now resolves the object's **source layer** by the recorded `pycat_layer_id` and draws the
  overlay (bbox + centre) with **that layer's `scale` and `translate`** (reconciled to the viewer's ndim so a
  2-D object in a 3-D viewer is handled). `resolve_in_viewer` centres the camera in **world coordinates**
  (`pixel-centre × scale + translate`). Scale-1.0 behaviour is unchanged (regression-tested).
- **Honest miss**: when the ref records a source layer that is **not open**, nothing is drawn and the miss is
  reported — an unverified box in the wrong place is worse than no box.
- **Resolve to the measured-on layer**, not the active one — so an upscaled workflow highlights on the
  upscaled layer (what the user sees and what the measurement used).
- **Hide the mismatched-resolution image** under the highlight: a visible image layer whose **pixel grid**
  differs from the target's (e.g. a 1024² source beside a 2048² upscaled layer) is hidden for the selection
  and **restored on clear** — comparing the grid shape, not the world extent. Only image layers, never the
  target, never a layer the user already hid, and never everything (if hiding would leave no visible image,
  nothing is hidden).
- Tests: `test_selection_overlay_scale.py` (`base`, 12 — scale/upscale draw, world-coordinate camera,
  legacy-unscaled, missing-source honest miss, multi-ref, and the hide/restore/attribution/never-hide-all
  rules) with a duck-typed viewer (napari-free). Existing brushing/selection tests pass unmodified. Parts 2
  (wire guided-panel execution) and 3 (saveable templates) remain.

## [1.6.328] - 2026-07-24
### Added — **A channel identity you name once is now recalled on STACK loads too, by acquisition layout (czi_sidecar_and_stack_identity Part 2).**
On the 2D path a remembered channel identity is recalled for the next same-layout file; the stack loaders named
each layer in isolation, so that memory never reached them. Now all three stack back-ends resolve the channel
set before naming.

- The IMS, generic (TIFF/CZI-structured), and CZI-streaming openers now **build the whole channel list, then
  resolve it once** through `resolve_channel_identity_on_load` (sidecar enrichment + recall by acquisition
  signature) **before** naming any layer — recall keys on the full list's signature, so it cannot be applied
  per channel. This replaces the per-channel sidecar enrichment with the shared resolver.
- **Precedence preserved and asserted:** real metadata > sidecar > remembered user answer > pixel/position
  guess (`apply_recalled_identities` only touches weak channels). A remembered answer applies to a **new file
  of the same layout** (signature, not path); a **different layout does not recall**; and it never overrides a
  metadata- or sidecar-named channel.
- A normal stack (no sidecar, no remembered answer) resolves to the identical list, so naming is unchanged —
  the stack characterization suites pass. `tests/test_load_channel_identity.py` +3 (`base`): recall-by-signature,
  different-layout-no-recall, metadata-not-overridden. Full `pytest -m "core or base"` green.
- **Scope, honestly:** this delivers RECALL on the stack path (the cross-path benefit — remember on a 2D load,
  recall on a same-layout stack). The stack loaders have no channel-identity *prompt*, so a fresh answer is
  still captured on the 2D dialog, not during a stack load. Resolution is per-loader immediately before naming
  (the one shared resolver) rather than at `_finalise_stack_load`, because recall must precede naming and the
  funnel runs after it. Live layer-naming on a real stack isn't reproducible in the headless gate.

## [1.6.327] - 2026-07-24
### Added — **CZI streaming loads now read a companion sidecar too — the last stack back-end gets channel naming, without moving the complexity ratchet (czi_sidecar_and_stack_identity Part 1).**
1.6.324 wired sidecar channel-naming into the IMS and generic stack openers but left CZI-streaming out, because
`_open_czi_streaming` sat exactly at the 120-line function ratchet and one more line would cross it. That reason
was contingent, not principled — so this splits the function and wires the sidecar.

- Extracted the libCZI-metadata preamble **verbatim** into `_czi_open_metadata` (returns
  `(image, microns_per_pixel)`), dropping `_open_czi_streaming` from 120 to 106 lines (ratchet metric) — the
  unreviewable-function count **falls by one**, the ceiling is untouched (never raised to fit a feature).
- Then wired the sidecar on the CZI path exactly as the generic path does: `sidecar_metadata_for` discovered
  once per load (non-gating, `None` when absent), each weak channel named from its emission via
  `enrich_channel_from_sidecar` above the pixel/position guess — real metadata never overwritten.
- The extraction is code-motion only (identical layers/shapes/scale/metadata by construction) and the sidecar
  wire reuses the unit-tested helper; CZI/stack regression suites green. Full `pytest -m "core or base"` green.
  (An end-to-end CZI load still isn't headlessly reproducible here — no CZI fixture / BioFormats in the gate.)

## [1.6.326] - 2026-07-24
### Added — **Imaris (.ims) files now expose the same per-channel `channels` schema as OME, so channel metadata is consistent across formats (deep_metadata item 4).**
`extract_ims_metadata` flattened per-channel data into single top-level fields, so any consumer that reads
`raw['channels']` / `raw['instrument']` (e.g. the metadata-contradiction surface) saw nothing for an .ims file
even though Imaris stores per-channel name / emission / excitation in its `DataSetInfo/Channel N` groups.

- New `_ims_channel_records` builds a `raw['channels']` list keyed by the **same `_OME_CHANNEL_KEYS`** the OME
  reader uses; Imaris exposes name / emission / excitation / color / gain, and the OME-only fields (fluor,
  contrast_method, acquisition_mode, detector_id, offset, binning, amplification_gain) stay `None` — missing
  stays missing, never defaulted. `_ims_instrument` adds the `raw['instrument']` block (NA + magnification).
- Additive: the top-level `emission_nm` / `excitation_nm` fill from channel 0 only when the image header
  carried none; every existing IMS field is unchanged.
- `tests/test_ims_channels.py` (`base`, 4, synthetic Imaris HDF5): the channels list mirrors the OME schema;
  top-level emission fills from channel 0; a file with no channel groups is safe (no `channels` key); the
  instrument block carries NA + magnification. Full `pytest -m "core or base"` green. Remaining item-4 readers:
  CZI / LIF / ND2 (need vendor fixtures).

## [1.6.325] - 2026-07-24
### Fixed — **Old saved sessions' entity ids now upgrade to the durable dataset UUID on load, so brushing and cross-session references resolve again (dataset_identity_uuid step 4).**
Since 1.6.191 a dataset's identity is a durable UUID, not its file path. Sessions saved before that embedded
the file PATH as the `dataset_id` prefix of every `_pycat_entity_id`. Because brushing and the entity registry
match the whole id string exactly, an un-migrated `path/…` id never matches a freshly-derived `uuid/…` id for
the same object — resolution silently failed. This upgrades them on open.

- New Qt-free `entity_ref.migrate_entity_id_dataset(df, old_dataset_id, new_dataset_id)` — a literal prefix
  swap (`f"{old}/"` → `f"{uuid}/"`) that leaves operation/type/frame/label byte-identical. The composite id is
  opaque and a path contains separators, so it is never parsed; the swap tolerates `/` vs `\` path spellings
  and is idempotent. After migration a restored id equals what a fresh stamp now produces for the same object.
- Wired into the session loader (`_apply_session_payload`): each restored dataframe is migrated using the
  manifest's `source_image.path` resolved to its UUID via the dataset registry, before it enters the
  repository. Non-gating — a no-manifest/loose load (no source path) or an unreadable file simply skips it, and
  a failure never blocks the load.
- `tests/test_entity_id_migration.py` (`base`, 7): prefix swap with the suffix intact; parity with a fresh
  stamp (the resolution guarantee); spelling tolerance; other datasets left alone; idempotent/no-op; safe on a
  df without the column; and end-to-end against the real registry UUID. Full `pytest -m "core or base"` green.
- Scope, honestly: current saves strip `_pycat_*` columns, so only pre-strip (pre-1.6.191) session CSVs carry a
  path-based id to migrate — the exact population this targets.

## [1.6.324] - 2026-07-24
### Added — **A companion sidecar now names stack channels on load too, so an ISS stack is never labelled "Brightfield" (sidecar Part 5, stack path).**
1.6.320 wired sidecar channel-naming into the 2D loader; this extends the enrichment to the stack load path.

- New Qt-free `enrich_channel_from_sidecar(ch_info, sidecar, channel_index)` — the per-channel counterpart of
  `enrich_with_sidecar`, for the stack back-ends that name each layer inside a loop rather than from an
  aggregated list. `enrich_with_sidecar` now delegates to it (shared logic, unchanged behaviour).
- Wired into the **IMS** and **generic (TIFF/CZI-via-structured-reader)** stack openers: the sidecar is
  discovered once per load (non-gating — `None` when absent) and each weak channel is named from its emission
  filter above the pixel/position guess, so an ISS `_Ch1`/`_Ch2` stack is named far-red / green and **never
  falls to `Brightfield`**. Real in-file metadata is never overwritten.
- `tests/test_load_channel_identity.py` +3 (`core`): a single channel is named from the sidecar entry at its
  own index; a metadata channel is left alone; the helper is non-gating. Full `pytest -m "core or base"` green.
- **Deferred, honestly:** the CZI-streaming back-end was left unwired (it sits exactly at the function-length
  ratchet, and CZI files carry their own structured metadata so the sidecar case does not arise there); and
  identity **recall/remember** on the stack path still needs the aggregated-channel-info restructure through
  `_finalise_stack_load` (the stack openers name each layer in isolation). Live in-napari validation of the
  stack dialog also remains outside the headless suite.

## [1.6.323] - 2026-07-24
### Changed — **Opening a large time-lapse no longer reads every page's tags — the cadence is sampled from a bounded prefix (deep_metadata item 6).**
A time-lapse TIFF carries its true frame cadence in a per-page `MicroManagerMetadata` tag. The load path used
to walk **every** page to read them — a 10,000-frame stack parsed 10,000 IFDs just to open. It now samples a
bounded prefix.

- `_extract_mm_frame_times_from_tiff` reads at most `_LOAD_PAGE_SAMPLE_CAP` (64) page records at load (both
  load call sites now pass the cap), and reports `pages_sampled` / `pages_total` so the bound is inspectable
  (surfaced in the TIFF metadata `raw`). A constant-cadence acquisition yields the same median interval from
  the prefix as from the whole stack, so no correct value changes; a caller that genuinely needs every plane's
  record still passes `max_pages=None` for the full on-demand read.
- `tests/test_per_page_metadata_bounded.py` (`core`, real MicroManager-tagged multipage TIFFs): the load path
  samples the cap, not all N pages; the bounded sample still recovers the 500 ms cadence; the full read is
  available on demand; a small stack reads only what exists. Full `pytest -m "core or base"` green. Remaining
  deep_metadata items: deepening CZI/LIF/ND2/IMS readers to the `channels` shape (item 4) and the Qt
  provenance/conflict surface.

## [1.6.322] - 2026-07-24
### Added — **Scientific-notation control for figure y-ticks — a shared ×10ⁿ exponent instead of `1e5` repeated on every tick (publication_features Tier 1, the last axis slice).**
Default matplotlib ticks look unfinished in print; a comparison figure of large or small values wants one clean
exponent, not the number spelled out on every tick.

- **`FigureSpec.sci_notation`** (`None` = matplotlib default · `'sci'` = a single shared ×10ⁿ offset via
  mathtext · `'plain'` = force plain), honoured by both `render()` and `refine()`. Pure `resolve_sci_notation`
  validates the request and **warns on an unknown value** rather than silently ignoring it (the same
  validate-and-warn discipline as `y_scale`). A **log** axis already labels in powers, so the control is
  skipped there; and a fresh `ScalarFormatter` is installed so it composes safely with `tick_format`.
- Round-trips through JSON like the other scalar fields; a bare spec is unchanged (default is off). Exposed in
  the comparative-figures refine bar as a dropdown beside the y-scale control.
- `tests/test_publication_features.py` +7 (Agg): the shared exponent appears under `'sci'` and is suppressed
  under `'plain'`; the default is a no-op that doesn't clobber `tick_format`; log axes are left alone; an
  unknown value warns; the pure helper and JSON/refine round-trips. Full `pytest -m "core or base"` green.
  **Tier 1 axis controls are now complete** (scales, minor ticks, error representation, scientific notation);
  the remaining Tier-1 item is significance-bracket UI exposure.

## [1.6.321] - 2026-07-23
### Changed — **Every load now pulls metadata from wherever it parses best, not just from the reader showing the pixels (deep_metadata Part 2 — the merge is finally wired in).**
The reader-independent merge (`extract_metadata_merged`) and the element-scoped OME parse shipped earlier, but
nothing on the live path *used* the merge — it was built and dormant. The dispatcher `extract_metadata` (the
entry point every load path calls) now routes through it.

- **Dispatcher adoption.** `extract_metadata` delegates to `extract_metadata_merged`, so every load gets
  field-level provenance (which source each field came from), two-source conflict findings, and — replacing a
  bare `except: pass` the spec explicitly called out — a **recorded** reason when a metadata source fails.
- **No currently-correct value moves (the hard caution).** `_default_metadata_sources` keeps the open pixel
  reader's own metadata at **highest precedence** whenever a handle is passed, so `pixel_size_um` and every
  other correct value are unchanged; a lower-precedence source (e.g. tifffile's baseline tags behind the
  structured reader) only **fills** fields the primary left empty. All 134 metadata/pixel-size gate tests pass
  unmodified.
- **Consumers keep working.** The merged `raw` preserves each source's top-level keys (`channels`,
  `instrument`, flat scan tags) rather than burying them under `raw_by_source`, so
  `metadata_contradictions` and `_fill_scan_acquisition_fields` read them where they always have.
- `tests/test_metadata_merge.py` +3 (`core`): the dispatcher routes through the merge and records failures;
  precedence keeps the open reader primary; merged raw preserves top-level source keys. Full
  `pytest -m "core or base"` green. Remaining deep_metadata items: deepening CZI/LIF/ND2/IMS readers to the
  `channels` shape (item 4), the Qt provenance/conflict surface, and lazy/bounded per-page TIFF metadata.

## [1.6.320] - 2026-07-23
### Added — **A companion sidecar now names channels on load, and a channel identity you give once is remembered for the same acquisition (sidecar_metadata Part 4/5 — the 2D load-path wiring).**
The mechanisms shipped earlier (sidecar discovery + ISS parser 1.6.289; the identity store + prompt 1.6.316);
this joins them into the live 2D loader so they actually fire on load.

- **Sidecar enrichment on load.** New Qt-free `file_io/load_channel_identity.py`; the 2D loader calls
  `resolve_channel_identity_on_load(file_path, channel_info)` right after reading the channels. It discovers a
  companion file (e.g. an ISS Vista `_fbs.xml`) and feeds its per-channel emission into naming **above** the
  pixel/position guess — so `im-*_Ch1`/`_Ch2` are named from their 647 / 525 nm filters (far-red / green) and
  **never fall to `Brightfield`** (the reported ISS bug). Real in-file metadata is never overwritten.
- **A remembered identity auto-applies.** The same call recalls any channel identity you saved for this
  acquisition layout and applies it as a confident `user` label; `_channels_all_confident` now counts `user`,
  so a second file of the **same layout is named automatically and never re-asks**.
- **Your answer is remembered.** When you type a name for a channel that carried no recoverable identity,
  `remember_user_channel_names` persists it (keyed to the acquisition layout; a blank or left-at-default answer
  is not stored) — so the recall above has something to recall.
- Non-gating throughout: any discovery/parse/recall failure just means the image loads with what the reader
  found. `tests/test_load_channel_identity.py` (`core` enrichment ×3 + `base` persistence ×4). Full
  `pytest -m "core or base"` green. Remaining: the **stack** load path (needs an aggregating channel-info list
  threaded through `_finalise_stack_load`) and live in-napari validation of the 2D dialog interaction.

## [1.6.319] - 2026-07-23
### Added — **The navigator shows "What we can tell from your data", and every control now explains itself (navigator-UX items 3 & 3b — the spec is complete).**
The last of the first-user feedback. The metadata layer already emits the facts; this surfaces them.

- **Item 3 — metadata pre-answers, made visible.** New Qt-free `session.data_observations(central_manager)`
  returns human-readable observations from the loaded image's metadata, each with its **evidence** — *"3
  channels · 200 timepoints"*, *"This looks like a time series — 200 timepoints, 0.5 s frame interval"*,
  *"This is multichannel — 3 channels"*. Nothing is invented: an observation appears only where the metadata
  supports it, and a time-series / Z-stack line needs the corroborating interval / step (a bare plane count is
  never enough). The navigator renders these as a **"What we can tell from your data"** section, clearly
  marked as read from the file, with the user's answers taking precedence (they already do — a user answer
  outranks metadata in `context_from_session`).
- **Item 3b — the explanatory layer, guarded.** The panel had shipped without one; now the mode toggle, every
  capability card, and every plan step carry a tooltip / stated meaning, and a test asserts it (the
  missing-layer guard) so it can't vanish wholesale again.
- Tests: a `base` observations test (evidence-backed, no-guessing) + `integration` (the section renders; the
  explanatory-layer guard). Full `pytest -m "core or base"` green. **Navigator-UX items 1–6 are all shipped.**

## [1.6.318] - 2026-07-23
### Changed — **Navigator comprehension: a load-data step 0, an explained step-colour legend, a self-explaining mode toggle, and Explore-capabilities in its own tab (navigator-UX items 2, 4, 5, 6).**
The remaining presentation/comprehension items from the first-user feedback (item 1, the re-evaluation bug,
shipped in 1.6.317).

- **Item 2 — load-data step 0.** `plan_rows(plan, ctx)` now prepends a visible **"Load data"** prerequisite,
  blocked until an image is open and satisfied once it is. It is a shown prerequisite, NOT a gate on the
  questionnaire (you can review the whole plan before loading; only *running* needs data). `plan_rows(plan)`
  without a context is unchanged, so existing callers are unaffected.
- **Item 4 — the colours are explained.** The reported "I don't understand the blue and green sections":
  a one-line legend at the top of the plan (green = ready · ⏳ blue = a QC probe runs first · ⚠ amber = runs
  with a caveat · ⛔ red = blocked) plus a per-step tooltip stating what its state means — reusing the existing
  status vocabulary, not a new colour scheme.
- **Item 5 — the mode toggle explains itself.** It now labels the OUTCOME ("Switch to Full" / "Switch to
  Guided") with a tooltip and a subtitle stating what each mode gives you. Behaviour of `app_mode` is
  unchanged — labelling only.
- **Item 6 — Explore capabilities is its own tab.** The Home dock splits the guided questionnaire and the
  capability explorer into two tabs, so the card list can grow without squeezing the questionnaire.
- Tests: a `base` step-0 test + an `integration` tabs smoke; the existing home/navigator smoke tests updated.
  `build_home_widget` refactored (render extracted to module scope) to stay under the complexity ceiling.
  Full `pytest -m "core or base"` green. **Item 3** (metadata pre-answers) is the last remaining piece.

## [1.6.317] - 2026-07-23
### Fixed — **The guided navigator now tracks the loaded data: loading an image re-enables the run action, and a disabled run always says why (navigator-UX first-user feedback, item 1).**
Reported from the first real guided session: *"run analysis did not light up"* — and after loading data and
after a restart, **nothing in the panel changed at all.** The plan was evaluated ONCE at compile and nothing
recomputed when the viewer changed, so the run action could never become enabled by doing the very thing it
was waiting for. Guided mode was inert as shipped.

- `navigator/planner.py`: new `regate(plan, ctx)` re-evaluates a compiled plan's context gaps and validity
  gates against a fresh context **without recompiling** the structure — the cheap path that lets a viewer
  event flip a blocked step to satisfied. (Recompiling could re-select modules on a cost tie-break and change
  the plan under the user — hence re-gate, don't recompile.)
- `navigator/session.py`: the session retains its plan and gained `regate()`, `run_blocked_reason()` (a
  user-language reason so the disabled run is never a dead control — *"Load an image first"*, *"Set the pixel
  size…"* — or `None` when runnable), and `context_from_session(central_manager)` which refreshes the context
  from the loaded image's metadata. **Metadata suggests, the user decides** (a user answer is never
  overwritten), and dimensionality is **never guessed from a bare plane count** (axes only from an explicit
  `dimension_order`; the 1.0 µm/px sentinel is not treated as a calibration).
- `ui/navigator_dock.py`: the widget takes `central_manager`, subscribes to layer insert/remove and
  calibration changes, **debounces**, re-gates + re-renders (and once on mount, so a panel opened with data
  already loaded is correct), and shows the blocking reason inline. Wired from `central_manager` and the home
  dock. `build_navigator_widget` was refactored (render/re-gate helpers to module scope) to stay under the
  complexity ceiling.
- `tests/navigator/test_navigator_reevaluate.py` (`base`, 6) + a re-gate Qt-smoke in `test_navigator_dock.py`.
  Full `pytest -m "core or base"` green. Items 2–6 (load-data step 0, metadata pre-answers, colour legend,
  toggle text, Explore tab) follow.

## [1.6.316] - 2026-07-23
### Added — **A last-resort channel-identity prompt for channels the metadata can't name, remembered per acquisition layout (sidecar_metadata Part 4).**
When a channel's identity can't be recovered from in-file metadata, the filename, or the pixel classifier, it
falls to a meaningless position guess ("C0-Blue"). This adds the mechanism to ask the user — only then,
optionally — and to remember the answer for future files acquired the same way.

- `utils/channel_naming.py`: `channel_needs_identity(channel_info)` — True only when a channel fell all the
  way to the position guess (`source == 'position'`); a channel identified from real evidence is never asked
  about (prompt-only-when-genuinely-missing).
- `utils/channel_designations.py`: `remember_channel_identity` / `recall_channel_identities` /
  `forget_channel_identity` — persist a channel-identity answer keyed to the acquisition **signature**
  (extending the SAME store as the condensate designation, never a second one; the two now coexist in either
  order). Opt-in (a blank answer is not stored, so a human answer only fills a genuinely-empty identity, never
  overwrites metadata), guarded to the current channel count, and reversible.
- New `ui/channel_identity_dialog.py`: `build_channel_identity_dialog` / `prompt_channel_identities` — an
  optional per-channel prompt shown ONLY for the unidentified channels, **never in batch/headless** (returns
  `None` without Qt). A skipped field stays unset. Built with a `harvest()` seam so it is Qt-smoke tested
  without a modal `exec`.
- `tests/test_channel_identity.py`: `core` (4) for the decision logic + persistence (round-trip, reversible,
  coexists with a condensate designation, layout-guarded) and `integration` (2) for the dialog. Full
  `pytest -m "core or base"` green. **Remaining:** wiring the prompt + recalled answers into the live load
  path (with the sidecar-metadata load integration).

## [1.6.315] - 2026-07-23
### Added — **The workflow preset picker: pick a declared, provenanced starting point that populates but never locks (analysis_presets Part B).**
The preset objects — the `AnalysisPreset` registry, its import-time honesty invariants (non-empty provenance;
`validated=True` must link a real validation case), availability gating, and the populate-not-lock
`PresetApplication` — were built and tested with no UI. This adds the picker surface.

- New `ui/preset_picker.py`: `build_preset_picker(workflow_id, *, available=None, on_apply=...)` lists the
  presets that apply to a workflow — each with its description, its **provenance** (where the numbers came
  from, mandatory), a validated / starting-point badge, and any caveats — and **greys** a preset the session
  cannot run yet with the reason (from `preset_availability`, which reuses the one requirements vocabulary,
  never a second gate). Choosing one produces a `PresetApplication` and hands it to `on_apply`, which seeds
  the workflow's parameter widgets; the user may then edit any value and the application tracks the deviation.
  The picker never locks a value and never invokes a workflow. Headless-safe (`None` without Qt or presets).
- `tests/test_preset_picker.py` (`integration`, 3 Qt-smoke): a runnable preset applies and seeds a
  populate-not-lock application; an unrunnable one is greyed with its reason; a workflow with no presets
  yields no picker. Full `pytest -m "core or base"` green. **Remaining:** embedding the picker in the grounded
  workflows and the live `PresetApplication.record()` into `batch_processor.record` — thin per-workflow wiring
  over this reusable control (its record shape is already pinned by test).

## [1.6.314] - 2026-07-23
### Added — **The metadata dialog lists acquisition contradictions first, with a reversible "expected for this instrument" control (tag_confidence Part 3 — the spec is complete).**
The engine that detects metadata contradictions (e.g. a Zeiss export declaring Oil immersion but Air medium
with oil's refractive index), classifies severity, and holds the per-instrument "expected" judgements was
already built, and the metadata toolbar button already warns when a critical one is present. This adds the
last piece — the dialog surface — so the warning is actionable, not just a glyph.

- New `ui/metadata_contradiction_panel.py`: `build_contradiction_panel(file_metadata)` lists the
  contradictions **ahead of the raw metadata** (critical first, concretely named), and offers on each a
  **reversible** "Expected for this instrument" control. Marking one greys it in the dialog immediately and —
  keyed to the acquisition fingerprint, **never the file** — demotes it to info everywhere (the button stops
  flagging it on the next refresh). There is deliberately **no "ignore all"**; suppression is per-pattern.
  Headless-safe (returns `None` without Qt, or when there are no contradictions — a clean file shows nothing).
- New Qt-free `contradiction_rows(file_metadata, store)` render model in `utils/metadata_contradictions.py`
  (carries each contradiction's ORIGINAL severity + its per-instrument expected status). Wired into the
  metadata dialog (`menu_manager._show_metadata_dialog`) in a guarded block that can never break it.
- `tests/test_metadata_contradiction_panel.py`: `core` (2) for the render model, `integration` (2) for the
  panel (marking persists + re-renders to "Unmark" + is reversible; a clean file yields no panel). Full
  `pytest -m "core or base"` green. **tag_confidence_and_metadata_validity (Parts 1–4) is now complete.**

## [1.6.313] - 2026-07-23
### Fixed — **The minimal test lanes no longer require the optional `openpyxl`, and the dependency guard now covers lazy imports (openpyxl-lazy-import spec).**
Two `base`-lane navigator tests failed with `ModuleNotFoundError: openpyxl` — the third failure of the class
where a minimal-lane test reaches a lazy optional import the module-scope dependency guard can't see. The fix
is not to install openpyxl into the minimal lane (that makes an optional dependency mandatory for the tier
whose purpose is proving PyCAT runs without extras); it is to decouple the minimal path from it.

- `navigator/scientific_tree.py`: the curated question tree (read from the shipped workbook via openpyxl) now
  loads **lazily** — on first access, not at construction. Merely constructing a `NavigatorSession` to edit or
  compile a plan no longer touches openpyxl; only actually DRIVING the question tree does.
- `tests/navigator/test_navigator_session.py`: the one test that drives the tree is re-marked `integration`
  (it genuinely needs the curated workbook); the rest stay `base`. A new `base` test asserts the navigator
  constructs, builds the standalone seed registry, and edits/compiles a plan with **openpyxl simulated absent**
  — the minimal-lane contract, now regression-proof.
- `tests/test_ci_dependencies.py`: new `test_no_undeclared_unguarded_lazy_import` extends the dependency guard
  to FUNCTION-scope imports — a lazy third-party import must be declared, guarded by a try/except fallback, or
  a documented known-lazy backend. This is what would have caught openpyxl at the source instead of in CI.
- openpyxl stays declared (the full navigator loads the curated tree); the minimal tier simply no longer
  depends on it. Full `pytest -m "core or base"` green. Flagged for a follow-on audit: several packages
  (`tifffile`, `pillow`, `imageio`, `packaging`) are imported directly but present only transitively.

## [1.6.312] - 2026-07-23
### Added — **A closed results dock reopens without recomputing, and clicking an off-page bead pages the plots to its track (results-dock spec Parts 2 & 3).**
Two more defects in the VPT results dock reported alongside the reflow bug.

**Part 3 — page-to-selection.** VPT pages thousands of tracks in buckets, and every bead is clickable —
including beads whose track is not in the displayed bucket. Clicking one used to promote its curve alone onto
the current page while the pager label still named a range that excluded it. Now a selection for an off-page
track moves the pager to the bucket that contains it (`_vpt_page_to_selected_track`, index recomputed from
`all_tids` and the *current* bucket size, never cached), re-renders, and re-highlights through the existing
path; an on-page selection — including a track visible in the page-0 ensemble — does not move the view.
`tests/test_vpt_page_to_selection.py` (`core`, 8) incl. bucket boundaries and a bucket-size-change-then-select.

**Part 2 — reopen a closed results dock.** Closing the panel left PyCAT holding a full set of computed results
and no way to see them short of re-running the analysis. New shared `utils/results_store.py`
(`retain_results` / `reopen_results` / `has_results` / `disabled_reason`): a workflow registers how to REBUILD
its dock from the retained payload, and a shared **'📊 Show results'** menu action
(`dock_space.install_show_results_action`) reopens the most recent — **never recomputing**; when nothing is
retained it states the refusal rather than silently starting a long run. VPT registers its payload and a
reopen restores the page/bucket the user left. Reopen reuses the existing dock-replacement path, so it is
idempotent (no stacked duplicates). `tests/test_results_store.py` (`core`, 8). Full `pytest -m "core or base"`
green. **The results-dock spec (Parts 1–3) is complete.** Follow-ons: labelling a payload whose source data
has since changed as "from an earlier run" (the `stamp` field exists for it), and cellular/batch adopting the
same `retain_results` call.

## [1.6.311] - 2026-07-23
### Fixed — **VPT results plots now reflow when you resize the dock (reported by Shamli Manasvi).**
The 2×2 microrheology plots were squashed to hairlines with overlapping axis labels, and dragging the dock
wider did not fix them. Cause: the figure was authored at a fixed 11×8.5 inch print size and laid out **once**
with `tight_layout` at draw, so the canvas stretched with the dock but the axes kept their old geometry.

- `toolbox/vpt/results_dock.py`: new `_new_results_figure()` builds a `Figure(layout='constrained')`, which
  recomputes subplot geometry on **every resize event** (and accounts for the suptitle, so the one-shot
  `tight_layout` is gone). The figure no longer carries the fixed print size — the canvas drives it.
- The canvas has an **Expanding** size policy and a **minimum size** below which the 2×2 grid is unreadable,
  so the dock can't be dragged into an unusable state; and the results dock is explicitly **floatable** with a
  tooltip — floating escapes the right-panel width entirely, the real answer for detailed inspection.
- `tests/test_vpt_results_reflow.py` (`base`, 3): constrained layout is enabled and the axes MOVE on resize
  (the core regression — the test resizes, it does not just check the first draw). Drawn content is unchanged.
- Part 1 of the results-dock spec; Parts 2 (reopen a closed results dock from retained results, no recompute)
  and 3 (clicking an off-page bead pages the plots to its track) remain. Full `pytest -m "core or base"` green.

## [1.6.310] - 2026-07-23
### Added — **The beginner Home dock: guided analysis + every capability as a card, with a Guided/Full toggle (navigator increment 4, part 2 — the increment is complete).**
The navigator, the feature-card catalogue, and the app-mode were all built and unreachable. This is the front
door that composes them, so a newcomer is greeted with a guided surface and can *see* what PyCAT can do.

- New `ui/home_dock.py`: `build_home_widget(central_manager)` renders the increment-3 **navigator**
  question-flow, the **capability cards** grouped by category (filtered to the current mode), and a
  **Guided/Full** toggle that flips and persists `app_mode` and re-renders live via its change signal.
  Headless-safe (returns `None` without Qt); all logic lives in the composed pieces, so the file is thin.
- `install_home_action(central_manager)` adds a '🏠 Home' menu action (tabify mount, 1.6.297) and, on a
  **genuine first run only**, greets the user with the dock once — conservative by design: it persists the
  beginner default so later launches don't auto-open, and it never changes the surface for anyone who has
  already chosen a mode. The menu action reopens it any time; nothing is ever hidden ("guide, don't cage").
- Wired into `central_manager` startup after the card registration, guarded so neither the action nor the
  auto-open can break launch.
- `tests/test_home_dock.py` (`integration`, 2 Qt-smoke): cards render and an advanced-only card is hidden in
  the beginner view; the toggle flips + persists the mode and reveals the advanced card; a card click invokes
  its real entry. Full `pytest -m "core or base"` green. **Navigator increments 1–4 are now all shipped.**

## [1.6.309] - 2026-07-23
### Added — **The feature-card layer: every shipped-but-hidden capability with a real opener is now catalogued (navigator increment 4, part 1).**
`utils/feature_registry.py` and `utils/app_mode.py` were built and tested with **zero consumers**. This adds
the first consumer — the registration that populates the catalogue at startup — so the beginner home dock
(part 2) has cards to render.

- New `utils/feature_cards.py`: `register_default_feature_cards(central_manager)` registers a `FeatureCard`
  for each capability that has a **verified real opener** — Data Quality dashboard, Control Validation,
  Spectral/Bleed-through Unmixing, Comparative & Publication Figures, and the **Feature Explorer** (a finished
  dock that had never been wired). Each card's `entry` lazily imports its opener, so the module stays Qt-free
  and `core`-testable; the registry never invokes an entry. `EXPECTED_CARD_KEYS` is the enumerated contract.
- **No placeholder cards.** Per the spec ("a card that opens nothing is worse than none"), capabilities that
  are compute-only with no UI surface — measurement stability, SMLM, kymographs, ratiometric, feature
  provenance, analysis presets — are deliberately NOT carded; each needs a real opener built first (follow-on).
- Wired into `central_manager` startup, guarded so a registration hiccup can never break launch.
- `tests/test_feature_cards.py` (`core`, 5 tests): the presence enumeration (a capability that forgets its
  card is caught), the no-placeholder rule (every entry is a real callable), idempotent-safe registration,
  categories, and beginner-visibility. Full `pytest -m "core or base"` green.
- **Remaining (part 2):** the beginner home dock that renders these cards + the Guided/Full mode toggle +
  the increment-3 navigator, with a guarded first-run auto-open.

## [1.6.308] - 2026-07-23
### Added — **The Navigator is reachable: a guided-analysis dock that drives the engine to a quality-gated, editable plan (navigator increment 3).**
The question engine, planner, and quality gates were all built and tested — and no user could reach them. This
is the last mile (increment 3 of the arc; it ships alone). It also fixes a latent gap: the engine loads its
scientific question tree from the shipped `navigator/data/*.xlsx` workbooks and needed **openpyxl**, which was
never declared — so the engine could not even construct on a clean install. openpyxl is now a dependency.
- New Qt-free **`navigator/session.py`**: `NavigatorSession` drives the existing `HybridQuestionEngine` +
  `Planner` — `next_question()` → `answer()` until `is_ready()`, then `compile_plan()`; editing is a **pin +
  recompile** (the planner re-validates every contract). `plan_rows()` renders a compiled `Plan` as ordered
  rows with each step's **quality-gate verdict inline**: a *blocked* step names why it cannot run, a
  *downgraded* step names its caveat but stays runnable, an *unknown* names the QC probe that will decide it —
  making increment 2's gate report visible instead of wasted.
- New thin **`ui/navigator_dock.py`**: renders the question flow (prompt + one button per choice), then the
  plan as step rows colour-coded by gate state (⛔ blocked / ⚠ downgraded / ⏳ probe), with a Run button that
  hands the compiled plan to the caller's executor and a Start-over button. `install_navigator_action` adds a
  '🧭 Navigator' menu action (from `central_manager`, not the line-capped menu god-file) that mounts the dock
  with the tabify behaviour (1.6.297). Headless-safe.
- **Reuse, not rebuild**: no new engine, gating vocabulary, or plan model — those exist. This is the
  presentation + drive layer the arc was missing.
- Tests: `tests/navigator/test_navigator_session.py` (`base`, 7 — the drive reaches a leaf and compiles; the
  blocked/downgraded/unknown reasons render; a satisfied step is clean; editing is a pin + recompile) and
  `tests/test_navigator_dock.py` (`integration`, 4 — the dock drives questions to a rendered plan, Run hands
  over the plan, Start-over resets, the installer mounts with tabify + is headless-safe). Full core green.
- **Follow-ons** (noted): the plan-execution bridge (`on_run` is wired but left `None` until a plan→ops
  executor lands) and increment 4 (beginner mode + feature-card surfacing).

## [1.6.307] - 2026-07-23
### Changed — **Finer test-marker tiers: `core` is now a true minimal lane; `base` for the scientific stack (release_engineering Part C).**
`core` used to mean "any headless test" and pulled the full scientific stack, so there was no way to check
basic health under a new interpreter without installing scipy/pandas/skimage/etc. — exactly what the Python
3.13 investigation lacked. `core` is now split into a genuinely minimal tier and a `base` tier, verified
empirically (not guessed from names):
- **`core`** — passes with **numpy + pytest only**, nothing else. The first lane to run under a new
  interpreter. Determined by actually running the suite in a numpy-only venv: 480 tests across ~118 files.
- **`base`** — headless but needs the declared scientific stack (scipy/scikit-image/pandas/matplotlib/opencv/
  simpleitk/seaborn/networkx/scikit-learn). 128 files re-marked `core` → `base` (every file with a test that
  needs more than numpy). Also declared: `gui`, `optional`, `slow`, `gpu` (vocabulary for future use).
- **conftest** ignores non-`core` files BY MARKER (an AST scan, no import) when the base stack is absent, so a
  `base` file's module-scope `pandas`/`scipy` import cannot break collection in the minimal lane.
- **CI**: the existing headless lane now runs `-m "core or base"` (the whole headless suite — **1816 passed,
  identical to before, zero coverage loss**); a new **`minimal` job** installs only numpy + pytest and runs
  `-m core` (the new-interpreter diagnostic); the wheel lane runs `-m "core or base"` too. The two Qt/GUI CI
  guards (`test_ci_dependencies`) now cover `core` **or** `base` — both lanes are Qt-free.
- **Safety by construction**: mis-marking toward `base` is harmless (the `core or base` lane covers it); a
  `core` test that needs more fails the minimal lane, which is how a bad mark is caught. Verified locally:
  minimal `-m core` green (480), `-m "core or base"` = 1816, guards + budgets green. No scientific behaviour
  changes. Part C completes release_engineering.

## [1.6.306] - 2026-07-23
### Added — **A wheel-install CI lane backs the `pythonpath` convenience (release_engineering Part B, complete).**
1.6.305 added `pythonpath = ["src"]` so a bare `pytest` runs from a clone, but the spec is explicit that this
must be backed by a lane that tests the built artifact — otherwise the source lane can pass while the wheel is
broken (a module or package-data file present in `src/` but missing from the artifact). This adds that lane.
- New **`wheel` job** in `.github/workflows/core.yml`: installs the same minimal compute deps as the `core`
  job, builds the wheel, installs it **`--no-deps` (not editable, not `src/`)**, and runs the **same `-m core`
  suite** against the installed package. `PYCAT_ALLOW_INSTALLED=1` satisfies the conftest working-tree guard
  (which otherwise refuses to test an installed copy), and `-o pythonpath=` clears the `["src"]` setting so
  `import pycat` resolves to the **wheel**, not `src/` — the whole point of the lane. The source lane and the
  wheel lane must agree.
- **Verified locally before landing** (a GitHub-only YAML change is otherwise unverifiable): installed the
  built wheel to an isolated directory, confirmed `import pycat` resolves to it (not `src/`), and ran the full
  `-m core` suite against it — **1816 passed**, identical to the source lane, including the packaging-sensitive
  tests (`test_headless_science` imports every toolbox module; `test_tag_resolver` uses the shipped binding
  table; `test_distribution_size` checks wheel contents). This is exactly what the CI job automates.
- release_engineering Part B is now complete; only Part C (finer marker tiers) remains.

## [1.6.305] - 2026-07-23
### Changed — **Correctness lint is now BLOCKING, and the Development Status is Beta (release_engineering Parts A + D).**
- **Part A — correctness ruff selection blocks CI.** The `F821,F823,F601,F811,B006,B023,B904` selection was
  advisory (`|| true`) "until observed green in a real run". Running real Ruff surfaced **65 findings** (not
  the near-zero the spec expected): 62 F811 redundant re-imports and 3 B023 closure-over-loop-variable — the
  latter three in the 1.6.295 `parse_ome_channels_and_instrument` (`_pick` now binds `ds_attr`/`det_attr` as
  defaults). All 65 were driven to zero (the redundant imports removed; one cross-module re-import confirmed to
  be the same re-exported object before removal), so the `|| true` was deleted — the canonical linter now
  **stops** these bug classes rather than only watching them. F841 (unused locals) and style stay advisory,
  per their existing review-required rationale.
- **Part D — `Development Status :: 4 - Beta`** (was `5 - Production/Stable`): accurate while the install
  matrix and Python-version support stabilise (the 3.13 ceiling that could not install, reverted in 1.6.302,
  is the case in point). Revisit when the clean-install matrix and a 3.13 lane are green.
- **Part B (partial) — `pythonpath = ["src"]`** so a bare `pytest` runs from a fresh clone. The dedicated
  wheel-install CI lane that must back it (import the built artifact, not `src/`) is the outstanding half of
  Part B and is explicitly noted as not-yet-packaging-verification. Part C (finer marker tiers) remains.
- No scientific behaviour changes; the removed imports were all redundant (the module-scope import already
  bound the name). Full core green.

## [1.6.304] - 2026-07-23
### Added — **Two guards against spec-drift test failures (collapse_mode_and_test_guards).**
The `collapse` reflow mode (Part 1 of the spec) was already implemented in 1.6.298 and hardened in 1.6.303;
this ship completes the spec with the two guards for the *class* of failure it belongs to — a test asserting
behaviour the module never declared — plus the descoping discipline that prevents it upstream.
- **Guard A** (`test_dock_space::test_guard_A_*`): the declared mode vocabulary must agree across the three
  places it lives — every entry in `VALID_MODES` is settable via `set_reflow_mode` without raising, is
  reachable from `plan_results_mount` (no declared-but-dead option), and equals the preference registry's
  options for `ui.results_dock_reflow` exactly. Catches a mode declared-but-unimplemented or the reverse.
- **Guard B** (`test_ci_dependencies::test_no_test_exercises_an_option_the_module_does_not_declare`): an
  AST scan asserting that any string literal a test passes to a declared-vocabulary option setter
  (`set_reflow_mode`) is in the module's `VALID_*` set — unless the call is inside `with pytest.raises(...)`
  (a legitimate rejection test). This is exactly the defect that let `collapse` be tested before it existed.
  Deliberately narrow and mechanical (scoped to setters with a `VALID_*` constant) so it can't become a
  broad "tests must match specs" check that false-positives and gets disabled.
- Also added `test_collapse_leaves_the_dock_mounted_if_resize_raises` (never-lose-the-dock when `resizeDocks`
  throws) and a DEV_NOTES section on the descoping discipline: when a spec defers an alternative, say so in
  the spec's *test* section ("do not write tests for X"), not only in prose. No product-code change; full
  core green.

## [1.6.303] - 2026-07-23
### Fixed — **Dock collapse no longer silently no-ops in the minimal (no-qtpy) core lane.**
The `collapse` reflow mode's `_apply_collapse` did `from qtpy.QtCore import Qt` before calling `resizeDocks`.
In the minimal CI `core` lane — which deliberately excludes the GUI stack, so **qtpy is absent** — that import
raised, the broad-except swallowed it, and `resizeDocks` was never called, turning the collapse into a silent
no-op. It surfaced as a failing `core` test (`test_collapse_mode_mounts_stacked_then_grows_the_results_dock`)
in that lane while passing everywhere qtpy happened to be installed.
- `_apply_collapse` now resolves `Qt.Orientation.Vertical` from qtpy when present but **falls back to the raw
  integer value (2)** when qtpy cannot be imported, so the collapse fires regardless of the Qt stack. (This
  path only ever runs against a real napari window in production, which always has qtpy; the fallback exists so
  the logic is not hard-coupled to the import, and the minimal-lane test exercises it.)
- Added a regression test (`test_collapse_fires_the_resize_even_when_qtpy_is_absent`) that blocks the qtpy
  import and asserts `resizeDocks` still fires — pinning the minimal-lane contract so it cannot drift back.

## [1.6.302] - 2026-07-23
### Fixed — **Python 3.13 is no longer declared installable: the ceiling now matches reality (python313_ceiling_revert).**
`requires-python` was `">=3.12,<3.14"`, permitting Python 3.13 while advertising only a 3.12 classifier — and worse, 3.13 cannot actually install: `cellpose<4` (a base dep) pulls `numpy<2.1`,
which ships no cp313 wheels, so pip falls to a numpy source build and dies for want of a C compiler. A 3.13
user got a confusing meson traceback pointing at nothing.
- **Reverted the ceiling** to `requires-python = ">=3.12,<3.13"`, so pip now refuses 3.13 cleanly ("requires a
  different Python") instead of failing three layers down. The ceiling carries an explanatory comment: the
  real (upstream) cause, the finding that the `cellpose<4` numpy pin is STALE (cellpose 3 segments
  byte-identically on numpy 2.3.5), and the unblock pointer (MouseLand/cellpose#1095, `known_issues.md`).
- **Docs corrected**: `installation.rst` wrongly claimed "supported range 3.12–3.13" — now states 3.13 is not
  yet supported and links `known_issues`. `known_issues.md` updated to `<3.13` with an explicit **Re-enable
  procedure** (bump ceiling → add the 3.13 classifier → re-verify byte-identical segmentation on real data →
  add a 3.13 CI lane).
- **Guard added**: `tests/test_python_version_ceiling.py` (`core`, 2) asserts `requires-python` and the
  `Programming Language :: Python ::` classifiers agree in both directions — every permitted minor is
  advertised, nothing advertised is outside the ceiling — the check that would have caught the original
  inconsistency and keeps the pair from drifting again. No scientific behaviour changes.

## [1.6.301] - 2026-07-23
### Added — **Metadata contradictions are now surfaced: the metadata button warns concretely (tag_confidence Part 3).**
The contradiction engine shipped in 1.6.293 (`utils/metadata_contradictions.py`) was wired to nothing — it
detected inconsistencies but nobody saw them. This drives it from real loaded metadata and shows the result on
the metadata button, following the spec's anti-numbing rules:
- New `contradiction_report({common, raw})` adapts loaded metadata to the engine — immersion/medium/refractive
  index come from the `raw['instrument']` block the deep-metadata OME parse now provides (1.6.295), the
  declared modality from the curated fields / first channel — runs `detect_contradictions`, applies the user's
  per-pattern 'expected' judgements, and returns a `ContradictionReport` (contradictions, fingerprint,
  `is_critical`).
- The **metadata toolbar button** gains a **warning glyph** (⚠, deliberately NOT the field_status step-status
  red — a distinct concept, per the colour-vocabulary caution) and a **concrete tooltip** ("Objective says Oil
  immersion; ObjectiveSettings says Air medium (RI 1.518 indicates oil)…") **only when a critical contradiction
  is present** — the single biggest anti-numbing lever, so most files show nothing. It **never blocks**;
  clicking still opens the metadata dialog. `install_metadata_indicator` refreshes the button whenever a layer
  is inserted (a new file loads its metadata, then adds layers) and is duck-typed so it is fully core-tested
  without Qt; it is wired from `batch_processor` (where the button lives), not the line-capped `menu_manager`.
- Cry-wolf preserved end to end: a clean file surfaces nothing; a pattern marked expected is demoted below
  critical (the warning glyph clears) while still listed.
- Tests (`core`, `test_metadata_contradiction_surface.py`, 7): the critical report from an oil-vs-air
  instrument block, the end-to-end cry-wolf clean case, expected-demotion, the warning-glyph-only-when-critical
  label, the concrete tooltip, and the installer (styles the button + refreshes on layer insert + never
  raises).
- **Follow-on:** listing the contradictions first inside the metadata dialog and an in-dialog "expected for
  this instrument" control — deferred because `_show_metadata_dialog` lives in the line-capped `menu_manager`
  god-file; the button indicator + tooltip is the primary anti-numbing surface and lands here.

## [1.6.300] - 2026-07-23
### Fixed — **The local-cache cleanup no longer gates startup: offered non-blockingly at the end of launch, reachable on demand (cache_cleanup_startup).**
The cache-cleanup dialog appeared as a modal **over** the napari splash while the window was still assembling —
the first thing a user met was a file-deletion decision about an app that had not finished presenting itself.
The safety reasoning ("the cache is provably idle before anything opens") was right, but the placement was
wrong: the cache stays idle for the *entire* launch, so the offer belonged at the end, not the earliest instant.
- **Deferred to the end of launch**: `run_pycat` no longer calls the cleanup immediately after viewer
  construction; instead it schedules a non-blocking offer (via `QTimer.singleShot`) after the branding and
  window setup, just before the event loop. The user sees a fully-formed, branded application first.
- **Non-blocking**: new `offer_cache_cleanup(viewer)` posts a napari notification with the cached amount
  pointing to *File ▸ Manage local cache…* — no modal on the startup path. An empty cache stays completely
  silent (the first-run experience is unchanged).
- **Reachable on demand**: new `open_cache_manager()` shows the SAME grouped keep/clear dialog (modal, because
  now user-initiated) and applies the choice, wired to a **'Manage local cache…' File-menu action**. The
  dialog, its grouping, per-file/-folder Keep, `KEEP_DAYS` protection, and 'never delete silently / report in
  the terminal' guarantees are reused **unchanged** — this changes *when* and *how* the cleanup is presented,
  not the dialog. (The menu action is installed from `central_manager`, not the line-capped `menu_manager`
  god-file.)
- Every safety guarantee holds: nothing is deleted without an explicit choice, `KEEP_DAYS` protection applies,
  a cleanup failure is swallowed so it can never crash launch.
- Tests: `test_cache_cleanup_startup.py` (`core` + one `integration` for the napari notification — empty-cache
  silence, the offer never raises, the on-demand manager shows the dialog + applies the choice, cancelled
  clears nothing, and a static check that launch defers the offer and no longer clears immediately) and
  `test_cache_menu_action.py` (`integration`, 2 — the File-menu action opens the manager; headless-safe).

### Fixed — **core-lane test collection: `pycat.file_io`-importing test modules now actually run headlessly.**
While adding the cache tests, found that `conftest.pytest_ignore_collect` silently skips any test module that
imports `pycat.file_io` / `pycat.ui` at **module scope** whenever an optional-stack package is absent
(aicsimageio is, post-bioio-migration) — so such `core`-marked modules were collected only when a file was
named explicitly, never in the full `pytest -m core` dir scan. The cache tests, and the metadata tests shipped
in 1.6.294–296 (`test_ome_xml_scoped_parse`, `test_ome_channels_and_instrument`, `test_metadata_merge`, ~21
pure-parsing core tests), were affected. Fixed by moving those imports off module top-level (a `lc` fixture or
a guarded `try/except` with `pytest.skip(allow_module_level=True)`), so the modules are collected and their
tests genuinely run in the core lane. These pure logic/parsing tests belong in `core`; only the napari/Qt
paths are `integration`.

## [1.6.299] - 2026-07-23
### Added — **A preferences panel: the persisted user preferences finally have a surface (unblocks the dock_space follow-on).**
PyCAT had accumulated persisted user preferences — `app_mode`'s beginner/advanced interface level and
`dock_space`'s results-dock placement — that were settable only in code, each owned by its own module with no
UI a user could reach. This adds the small preferences surface they were missing:
- New Qt-free **`utils/preferences.py`** registry: `list_preferences()` enumerates every user-adjustable
  preference as a `PreferenceView` (stable key, label, help text, discrete options, current value), and
  `set_preference(key, value)` forwards a change to the owning module (`app_mode` / `dock_space`), which
  validates and persists it. Unknown keys / out-of-range values raise rather than silently no-op. Adding a
  future preference is one registry entry — the UI needs no change. Core-tested (no widgets).
- New thin **`ui/preferences_dialog.py`**: `build_preferences_dialog()` renders one radio group per
  preference from the registry with the current value pre-checked; a change applies immediately through
  `set_preference` (matching how the underlying preferences already notify subscribers live).
- **Entry point without touching the god-file**: `install_preferences_action(viewer)` adds a '⚙ Preferences'
  menu-bar action, installed once from `central_manager` right after the menu bar is built — deliberately
  NOT accreted onto `menu_manager.py`, which is pinned at its line-count ceiling. Headless-safe (no
  `_qt_window` → clean no-op).
- This delivers the third `dock_space_management` follow-on (exposing the reflow preference in a settings UI),
  previously deferred for want of a preferences panel — the results-dock placement is now the second control
  in that panel, alongside the interface level.
- Tests: `test_preferences.py` (`core`, 6 — registry list/forward/validation/options-cover-the-owner) and
  `test_preferences_dialog.py` (`integration`, 5 — the dialog renders + persists, and the installer adds a
  real menu action / is headless-safe). Full core green (1770).

## [1.6.298] - 2026-07-23
### Added — **Dock-space follow-ons: a real `collapse` mode and a Qt-smoke test of the actual tabbed mount.**
Two of the three follow-ons noted on the `dock_space_management` spec:
- **`collapse` mode** is now a real third option beside `tabify` (default) and `stack` (opt-out). For users
  who prefer the stacked mental model, collapse mounts the results dock stacked and then grows it via
  `QMainWindow.resizeDocks` — a **native Qt primitive**, not hand-rolled reparenting — so the tall method
  panel shrinks to give room while staying open and reversible by dragging the splitter, with its parameter
  state untouched. Headless-safe: no `_qt_window` → a clean no-op that leaves today's stacking.
- **Qt-smoke tests** (`integration`) exercise the reflow against a **live `QMainWindow` with real
  `QDockWidget`s**: tabify genuinely tabs the results dock with the method dock (asserted via Qt's own
  `tabifiedDockWidgets`) and the method widget is never reparented (`.widget() is method`, text intact);
  collapse invokes the real `resizeDocks` on the results dock without tabbing. These verify the actual Qt
  primitives the design relies on, beyond the fake-window unit tests.
- Tests (`test_dock_space.py`): +2 `core` (collapse mounts-stacked-then-grows via a recording fake-Qt window;
  collapse no-ops without a Qt window) and +2 `integration` (the live-window tabify and collapse smokes).
  Full core green (1770).

The third follow-on — exposing the preference in a settings UI — remains **deferred**: the codebase has no
preferences/settings panel, and the only menu surface is the contract-locked `menu_manager` god-file already
flagged for decomposition, so a proper home is a small future preferences panel rather than more accretion on
that file. The preference is fully functional today via `dock_space.set_reflow_mode`.

## [1.6.297] - 2026-07-23
### Fixed — **Results docks now get room: they tabify with the tall method panel instead of being starved below it (dock_space_management).**
A brushable results dock was appended *below* the method widget in the same right-hand dock area, and Qt split
the height between them — so on a method whose parameter panel is very tall (VPT), the results (the plots and
linked table that are the whole payoff of brushing) got almost no space and were effectively invisible.
- New `utils/dock_space.py` (Qt-free — the napari window is duck-typed) reflows the dock **area** rather than
  touching the method widget. `add_results_dock(window, widget, name=...)` mounts a results dock with napari's
  `tabify=True` by default, so it becomes a **tab** alongside the method panel: full panel height, parameters
  one tab-click away, reversible (drag the tab out), obvious (tabs are visible).
- **State is guaranteed intact**: tabify never reparents, rebuilds, or clears the method widget, so its
  entered parameter values and field-status markers survive untouched — the main risk the spec flags is
  sidestepped entirely (chosen over a hand-rolled collapse, which has no native Qt primitive and would fight
  napari 0.7.x).
- **Opt-out and general**: the `ui.results_dock_reflow` preference (`'tabify'` default, `'stack'` = today's
  behaviour) is remembered per user; the mount is idempotent (a second results dock does not re-reflow) and
  headless-safe (no window → clean no-op); a napari without the `tabify` kwarg falls back to a plain stacked
  mount so results never vanish. Implemented once in the shared helper and routed through all four results
  mounts (VPT, batch, cellular-object, IVF-droplet) so any future method inherits it.
- Tests (`core`, `test_dock_space.py`, 9 tests): default-favours-visibility, persistence + unknown-value
  fallback, unknown-mode rejected, the pure plan (backward-compatible/safe/idempotent), headless no-op, tabify
  kwarg passed, stack reproduces today, already-reflowed no re-tabify, the never-lose-the-dock fallback. Full
  core green (1759).

## [1.6.296] - 2026-07-23
### Added — **Reader-independent metadata merge: metadata from wherever it parses best, with per-field provenance and reported conflicts (deep_metadata_and_naming Part 2).**
The best reader for pixels is not always the best for metadata, but today they are the same choice — a format
that displays well can lock you out of metadata another library would parse fully. The seed already existed
(`extract_metadata` tries a second reader for `pixel_size_um` alone, TIFF-only, wrapped in a bare
`except: pass`). This generalises it into a real merge policy:
- New **`merge_metadata_sources(sources)`** (pure, Qt-free) merges an ordered `(name, common)` list by
  **field-level precedence**: the first source with a *meaningful* value (reusing `metadata_validity.
  is_meaningful`, so a `'N/A'` placeholder never beats a real value from a lower source) wins each field, and
  **`sources[field]` records where it came from** — a user can see "NA came from OME-XML; pixel size from
  tifffile tags".
- **A two-source disagreement is a finding, not a silent merge**: recorded with both values, the winner, and
  the reason. A `pixel_size_um` conflict is marked `surfaced` so the pixel-size gate sees it (a wrong scale
  corrupts every physical measurement). Rounding differences (0.1035 vs 0.10350001) are not a conflict.
- New **`extract_metadata_merged(file_path, *, sources=None)`** orchestrates it, asking each source for the
  format (tifffile + OME-structured for TIFF; IMS HDF5 for `.ims`) — and **skips a failing source with a
  recorded reason** (`raw['source_failures']` naming the source and the exception) instead of the old bare
  `except: pass` that swallowed it. The pixel reader is unaffected; display still routes through whichever
  reader shows the pixels best. `sources` is injectable for testing.
- The merge trail rides in `raw`: `metadata_sources` (per-field provenance), `metadata_conflicts`, and
  `raw_by_source` (each source's raw blob preserved). Additive — `extract_metadata` is unchanged.
- Tests (`core`, `test_metadata_merge.py`, 8 tests): precedence + provenance, placeholder-skipping, the
  recorded/surfaced conflict, rounding-is-not-conflict, a failing source recorded not swallowed, the
  preserved merge trail. Full core green (1759).

## [1.6.295] - 2026-07-23
### Added — **Per-channel + instrument OME schema: the hierarchy the flat schema cannot express (deep_metadata_and_naming Part 1, cont.).**
OME metadata is hierarchical and per-channel (Instrument → Detector → Channel → DetectorSettings). The flat
`_empty_common` schema flattened a three-channel Zeiss LSM export — Ch1 = 405/447 DAPI on Detector:1 at gain
600; Ch3 = a transmitted PMT (ContrastMethod "Other") at a different gain — down to one excitation/emission
pair, discarding exactly the per-channel acquisition parameters the calibration fingerprint and modality
gating need. New `parse_ome_channels_and_instrument(xml_string)` is **purely additive** — the flat fields are
untouched — and exposes the structure alongside them:
- **`channels`**: one dict per `<Channel>`, read from its own element: `index, name, fluor, excitation_nm,
  emission_nm, contrast_method, acquisition_mode, detector_id, gain, offset, binning, amplification_gain,
  color`. Detector settings resolve **per channel** — a `<DetectorSettings>` child wins, falling back to the
  referenced `<Detector>` element for anything it omits (gain lives on one or the other by vendor). The
  transmitted PMT channel is distinguishable by `contrast_method == 'Other'` with no dye.
- **`instrument`**: `lens_na, nominal_magnification, immersion, medium, refractive_index, dimension_order`.
  The objective's `Immersion="Oil"` and ObjectiveSettings' `Medium="Air"` (RI 1.518 = oil) are **both
  recorded** — the real ZEN-export contradiction is surfaced, never silently resolved.
- **Missing stays missing**: numeric attributes coerce to int-if-integral / float / `None` (never a default,
  generalising the pixel-size "unknown is NaN not one" contract); every channel and the instrument dict carry
  the full canonical key set.
- Wired into `extract_reader_metadata`: `raw['channels']` / `raw['instrument']` attach only for OME blobs
  with real structure, and the objective NA lifts into flat `common['numerical_aperture']` when it was
  otherwise unknown (the Nyquist/diffraction-limit QC consumer).
- Tests (`core`, `test_ome_channels_and_instrument.py`, 7 tests): per-channel read, the transmitted-PMT
  distinction, detector-gain resolution order (settings-then-detector), numeric coercion, the recorded
  contradiction, the canonical key set, malformed/non-OME → empty. Full core green (1759).

## [1.6.294] - 2026-07-23
### Fixed — **OME-XML metadata is read from the RIGHT element, and XML is no longer mis-parsed as ImageJ key=value (deep_metadata_and_naming Part 1).**
The OME-XML branch of `parse_description_blob` regexed the WHOLE document for 10 fixed attribute names and
took the first match. On a real Zeiss LSM export the pixel dtype `Type` resolved to `PMT` — the first `Type=`
in the file belongs to `<Detector Type="PMT">`, which precedes `<Pixels Type="uint16">` — so the detector
category was silently recorded as the pixel dtype. Multi-image files cross-contaminated the same way.
- New `_parse_ome_xml_scoped(xml_string)` parses with `ElementTree` (namespace-agnostic), reading each
  attribute from the element it belongs to: the first `<Pixels>` element's `PhysicalSizeX/Y/Z`, `SizeT/C/Z`,
  `Type`, `DimensionOrder`, `TimeIncrement`; the `<AcquisitionDate>` **child element** (an element, never an
  attribute — the old regex `AcquisitionDate="` could never match it); and the first `<Plane>` exposure. The
  scoped value **wins**; the old whole-string regex remains only as a gap-filler, so nothing already-correct
  regresses. Unparseable OME returns `{}` and falls back to the regex — never crashes.
- OME-XML is now detected **before** the ImageJ `key=value` branch. XML whose attributes wrap across lines
  (`PhysicalSizeX="0.1" SizeC="3"` on a continuation line that does not start with `<`) was otherwise
  captured by the greedy line-oriented ImageJ parser, which returned garbage before the OME branch ran.
- Tests (`core`, `test_ome_xml_scoped_parse.py`, 6 tests): `Type` is the pixel dtype not the first `Type=`;
  pixel-size/geometry unchanged; the `<AcquisitionDate>` child is read; the scoped parser in isolation;
  malformed XML → `{}` → regex fallback recovers the value; a non-OME blob is untouched.

## [1.6.293] - 2026-07-23
### Added — **Metadata-contradiction engine: detected, severity-graded, cry-wolf-clean, anti-numbing per pattern (tag_confidence Parts 3–4 core).**
A file can carry internally inconsistent metadata (a Zeiss ZEN export saying the objective is oil while
ObjectiveSettings say the medium is air — RI 1.518 giving it away) or metadata that disagrees with the
pixels. Showing every such disagreement on every file trains the user to ignore them. New Qt-free
`utils/metadata_contradictions.py` keeps the signal meaningful:
- `detect_contradictions(metadata, *, pixel_modality)` finds them, most-severe first, **never blocking, with
  metadata always winning**: an oil-vs-air immersion/medium inconsistency is **critical** (it changes the
  effective NA and microns-per-pixel) and named concretely with the RI cross-check; a declared-vs-pixel
  modality mismatch is **info** (metadata wins, recorded). `has_critical` is the sole trigger for a red
  indicator — info-only files show nothing.
- **Cry-wolf discipline enforced by tests:** a clean, consistent file raises ZERO; wording differences
  ('widefield fluorescence' vs 'fluorescence') are the same category, not a contradiction.
- **Anti-numbing store** (reusing `user_settings`): a pattern marked "expected for this instrument" is
  **demoted to info, not deleted** — keyed to the acquisition FINGERPRINT (never the file, or the user
  re-dismisses forever), **reversibly**, **per-pattern only (no 'ignore all')**, and with a developer-facing
  precision signal (`rules_dismissed_across_many_fingerprints`) so a rule dismissed across many setups is
  fixed rather than absorbed.
- Tests (`core`, `test_metadata_contradictions.py`, 8 tests): detection + concrete message + RI cross-check,
  info-vs-critical, cry-wolf, fingerprint-not-file keying, per-pattern suppression, reversibility, the
  developer precision signal. The red button indicator + tooltip + dialog listing (the Qt surface over this
  engine) remain the follow-on; immersion/medium/RI field extraction feeds it as those land.

## [1.6.292] - 2026-07-23
### Changed — **Confidence scores the EVIDENCE, not the source label (tag_confidence Part 1): a binary pixel call is decisive or `None`, and metadata is graded within its source.**
Confidence numbers were a coin flip. `classify_channel_from_pixels` reported a heuristic sum (0.5–0.7) for a
binary fluorescence-vs-transmitted call — but a coin flip on a 2-way question is already 0.5, so those
numbers carried no information. And `navigator/tags._SOURCE_CONFIDENCE` gave a flat `metadata: 0.8` whether
the file *declared* `ContrastMethod="Fluorescence"` or merely hinted it in a filename.

- **`utils/channel_modality.py`**: a DECIDED binary call is now reported in the inferred-evidence band
  **[0.70, 0.95]** (via `_binary_confidence`, scaled by the winner's margin); a call at/below its 0.5 chance
  level — or a tie — returns modality **`None`** (no decision), never a near-chance number dressed up as low
  confidence. The finer transmitted sub-type is a genuine 3-way call, so it may sit between its 1/3 chance
  and ~0.90. Garbage still degrades to `(None, 0.0)`; the caller's `conf >= 0.5` gate is unaffected.
- **`navigator/tags.py`**: new `confidence_for(source, evidence)` grades WITHIN the metadata source —
  declarative field → **0.99**, unambiguous derived evidence (emission nm → spectral bucket) → **0.90**, a
  weak name/filename hint → **0.70** — with the flat `0.8` kept only as the fallback for a metadata tag of
  unstated evidence kind. `TagSet` gained an `evidence` field that threads through the factory. The full
  confidence scale is now documented in-code so a value is interpretable, not decorative. `user: 1.0`,
  `pipeline: 0.95`, `derived: 0.85` are **unchanged** (per the spec).
- `tests/test_tag_confidence.py` (`core`, 9 tests): the binary band + None-below-chance invariant, the
  metadata grading, the unchanged source values, and that the documented scale matches the emitted numbers.
  Full `pytest -m core` green (1742). Part 2 shipped 1.6.290; Part 3 (contradiction surfacing) remains.

## [1.6.291] - 2026-07-23
### Fixed — **Batch replay now mirrors the interactive GUI it is replaying (merge of Meet Raval's `Meet-Raval-fixes`).**
A batch run is supposed to reproduce the recorded interactive session exactly. Meet Raval's commit fixes a
class of bugs where a replay step ignored the recorded `method`/parameter and always ran one hardcoded path,
so the batch silently produced different data than the session it claimed to replay.

- **SACF** replay imported `sacf_per_cell_per_slice`, which does not exist anywhere -- every batch SACF run
  raised `ImportError`. It now dispatches on the recorded `mode` (LIR / whole-image / drawn-rectangle, the
  last skipped with an explicit message since the ROI is drawn interactively).
- **IVF preprocessing** now honours the recorded `method` (gaussian / LoG / rolling-ball) instead of always
  running the rolling-ball pipeline; **IVF field summary** honours the recorded `image_layer` and dark
  reference and uses `partition_coefficient_local` -- matching the GUI (`invitro_fluor_ui`), not the older
  percentile-based `partition_coefficient_field`.
- **Brightfield / IVBF**: flat-field reference layers are resolved from split-file/channel state; IVBF
  segmentation dispatches on the recorded `method` instead of always running `intensity`.
- **Preprocessing** recomputes the rolling-ball size cap per image (the GUI caps it but never persisted the
  capped value) and scales `object_size` on upscale; **split-file recordings** (separate `open_image` steps)
  no longer reload the primary file twice -- each companion is located per batch sample.
- **Segmentation** (`fz`): large-condensate rim-closing now also requires few contributing components and a
  bounded fill ratio, so a dense field of genuinely separate puncta is not merged into one blob.
- The interactive UI records the `method` / `sigma` / `image_layer` / dark-reference / suppression params the
  replay fixes consume.

Merged with Meet's authorship preserved. The `ivf_segmentation` operation mapping was removed (a fixed op
tuple was misleading for 4 of its 5 recorded methods), lowering `_MIN_STEPS_DECLARED` 10→9 -- a deliberate
correction, not a neglect regression. Integration commit kept the complexity/exception-budget ratchets intact
(extracted `_replay_split_file_companion`, condensed comments) rather than raising them. Full `pytest -m core`
green (1747).

## [1.6.290] - 2026-07-23
### Added — **A shared metadata validity filter: a present-but-meaningless value can no longer masquerade as real (tag_confidence_and_metadata_validity Part 2).**
A present-but-meaningless metadata value is worse than an absent one -- it looks authoritative, suppresses the
prompt that would have asked, and silently satisfies gates meant to catch missing information. Real files show
it: a Zeiss export writes `Detector Model=""`, `PositionX="NaN"`.

- New `utils/metadata_validity.py`: `is_meaningful(field, value)` rejects empty/whitespace strings,
  placeholder tokens (`unknown`, `N/A`, `none`, `null`, `-`, `?`, ...), non-finite numbers, and field-aware
  sentinels -- a `pixel_size` of 1.0, a `gain`/`magnification`/`NA` of 0 -- while NEVER blanket-rejecting the
  number 1 (`binning`, `amplification_gain`). The zero rule is precise, not a substring smear (`channel_name`
  is untouched). `rejection_reason` explains a discard so it is visible, not silent. A rejected value leaves
  the field None, which correctly triggers the existing gates.
- Applied at the metadata write guard (`parse_description_blob`), so placeholder values in a MicroManager
  summary are dropped rather than recorded.
- `tests/test_metadata_validity.py` (`core`, 28 tests) covering the placeholder set, the field-aware
  sentinels, and the guard application. Full `pytest -m core` green (1747). Parts 1 (evidence-graded
  confidence) and 3 (contradiction surfacing) of the spec remain.

## [1.6.289] - 2026-07-23
### Added — **Sidecar metadata discovery + the ISS Vista parser (sidecar_metadata Parts 2-3): a companion file's channel identity is found and read, bounded and never gating.**
Some images carry no channel identity at all (a plain 2-D TIFF from ISS Vista), but a companion file beside
them does (`im-1-FUS-PLD-1_fbs.xml` next to `im-1-FUS-PLD-1_Ch1.tif`). This adds the mechanism to find and
read it.

- New `file_io/sidecar_discovery.py`: a `SidecarParser` registry, `discover_sidecar(image_path)` (progressive
  stem matching — the full stem, then with a channel/position suffix stripped, then with a known sidecar tag;
  the image's OWN directory only, no recursion, a hard entry cap), and `sidecar_metadata_for` (discover +
  parse). **Never raises, never gates** — a miss/failure/slow scan just means the image loads with its in-file
  metadata. New instruments are added by registering a parser, not editing the loader.
- The ISS Vista `_fbs.xml` parser (Part 3, proving the mechanism): parses the sectioned free-text
  `<fromComments>` into per-channel records — Ch1 647/57 nm + APD, Ch2 525/50 nm — plus lasers (488, 635),
  objective magnification (60), and dwell time, and records `modality='fluorescence'` justified by the
  emission filters + APD detectors (never a bare assertion, never brightfield).
- `tests/test_sidecar_discovery.py` (`core`, 6 tests) incl. the bounded-scan (no recursion) and non-gating
  (absent → nothing, never raises) guarantees. Full `pytest -m core` green (1719). REMAINING: wiring
  discovery into the live load path + the naming tiers, and the last-resort channel-identity dialog (Part 4)
  — recorded in docs/audits/TODO_gui_verification_and_followons.md.

## [1.6.288] - 2026-07-23
### Changed — **Brushable workspace polish: image picking no longer depends on the active napari layer, and a reloaded session re-opens its brushable panel.**
Two follow-ons to the brushable-results-workspace feature.

- **Viewer-level pick dispatcher.** napari delivers a *layer's* mouse callbacks only to the ACTIVE layer, so
  per-layer picking made "which tier picks" depend on which layer happened to be selected. The workspace now
  installs ONE viewer-level handler that tries its image tiers **finest-first** (the condensate layer before
  the cell layer), so a click resolves to the most specific object under the cursor regardless of the active
  layer. `BrushableImageTier` gained a reusable `pick_at(position)` and an `install_callback` flag; the
  handler is removed on `detach()`.
- **Session-load re-mount.** Loading a saved cellular session now re-opens the two-tier brushable panel
  (`_remount_brushable_panel` in `session_loader`), reading the restored entity-stamped cell/puncta tables +
  the cell and per-punctum labels layers — no persisted panel needed, and a punctum keeps its identity
  (deterministic from the durable `dataset_id`).
- `tests/test_brushable_workspace.py` + `tests/test_cellular_brushable.py` gain the dispatcher (finest-tier,
  active-layer-independent, removed on teardown) and the remount (mounts for a cellular session, no-ops
  without a cell table). Full `pytest -m core` green (1719).

## [1.6.287] - 2026-07-23
### Added — **Batch brushable results — the same plots+tables over every image, where a point pulls up its object's image OFFLINE (brushable-workspace Phase 4; feature complete).**
`consolidated_long.csv` carries a stable `entity_id` (enough to brush plots ↔ tables) but the bbox was melted
away and the source path never stored, so a batch point could not become an image. The per-image
`<stem>_cell_df.csv` / `puncta_df.csv` files DO keep the bbox and entity id — this reads them back and closes
the loop.

- New `utils/batch_brushing.py`: `assemble_batch_object_tables` reads every image's per-image object CSVs,
  tags each row with its originating image path (`_pycat_source_path`), and concatenates by object type — so
  each row's `ObjectRef` is `is_resolvable_offline()` (source path + bbox). `mount_batch_workspace` builds the
  plots + cell/condensate tables + a `BatchCropView` (the batch "image": a selection resolves the object's
  crop via `crop_for_ref(viewer=None)` → `resolve_offline`, opening the source file and slicing the bbox —
  no session, no re-segmentation).
- Wired into `batch_processor._on_finished` (best-effort; a brushing failure never taints a completed batch).
  `BrushableImageTier` now reads a per-row `_pycat_source_path` too.
- `tests/test_batch_brushing.py` (`core`, 3 tests): assemble tags each row; a batch row resolves to its image
  offline; the mounted workspace pulls up the correct image on selection. Full `pytest -m core` green (1717).

This completes the brushable-results-workspace feature: one reusable panel (plots left, tables right, one
selection service) drives the in-vitro, cellular two-tier, and batch cases; live brushing reveals in napari,
batch brushing pulls crops offline.

## [1.6.286] - 2026-07-23
### Fixed — **a z-step hidden in a TIFF PageName is now recovered, with a pixel-size cross-check (sidecar_metadata Step 1b).**
`PageName = "…, VoxelSize=0.0977x0.0977x19.0000"` carries the 19 µm z-step (and confirms the in-plane pixel
size) that the structured pixel-size object never sees, so `z_step_um` came back `None`.

- `metadata_extract._parse_voxelsize` parses `VoxelSize=X x Y x Z` (µm) from the `PageName` tag;
  `extract_tiff_metadata` fills `z_step_um` from Z and, for the in-plane size, cross-checks X against
  XResolution — agreement confirms it; a disagreement beyond 2% is recorded in `common['conflicts']` (with
  XResolution keeping the value) rather than silently resolved, per the metadata-validity rule.
- New `tests/test_pagename_voxelsize.py` (`core`, 4 tests). This completes Part 1 (the two immediate bug
  fixes) of the sidecar-metadata spec; the sidecar-discovery / ISS-parser / channel-dialog parts remain and
  need the real ISS fixture files.

## [1.6.285] - 2026-07-23
### Fixed — **a 2-D TIFF whose pixel size came from the file now shows a µm scale bar, not "px" (sidecar_metadata Step 1a; reported by Meet Raval on an ISS Vista file).**
The pixel size and its provenance were both read correctly, but the 2-D load path set
`pixel_size_from_metadata` True only inside the 1.0-sentinel recovery branch. A file whose scale came
straight from `tiff_tags` (the reader succeeded, so no recovery ran) kept the flag False, and
`napari_adapter._is_calibrated` then rendered a `px` scale bar on a correctly-calibrated image.

- `file_io.py`'s 2-D path now routes the flag through the ONE provenance helper
  `_calibration_is_from_metadata` (which reads `pixel_size_source`) whenever a real (non-sentinel) pixel size
  is present — the same helper the stack path already uses. Guarded on the 1.0 sentinel so a scale rejected as
  implausible is never resurrected.
- New `tests/test_sidecar_metadata_step1.py` (`core`, 4 tests) including the spec's guard that **no site sets
  `pixel_size_from_metadata` by a bare value comparison** (the one documented fallback lives inside the
  helper). Full `pytest -m core` green (1714). The larger sidecar-discovery / ISS-parser / channel-dialog
  parts of the spec remain (they need the real ISS fixture files).

## [1.6.284] - 2026-07-23
### Added — **Cellular object analysis (Fluorescence): a two-tier brushable panel — cell AND condensate both brush the plots, tables, and image (Phase 3).**
Meet's primary ask. The Csat plot (condensate vs total intensity) and the dilute-vs-total plot stack on the
left; the cell-wise and condensate-wise tables stack on the right; and TWO interleaved image tiers — the
cell-labels layer and a new per-punctum labels layer — let a click on a cell OR a condensate in the image, a
plot point, or a table row light up the matching object everywhere.

- `puncta_analysis_func` now paints a GLOBALLY-unique per-punctum labels array alongside the existing
  cell-labeled one (whose values are cell labels and so cannot key a click to a punctum), and stamps a
  `global_punctum_label` column mapping each `puncta_df` row to it. `run_puncta_analysis_func` adds the
  `'Condensate Labels'` layer and `attach_layer_id`s it — so a **punctum keeps its identity across a
  save→load** (the label array and the entity-stamped table both persist, and the entity id is deterministic
  from the durable `dataset_id`). Additive: no cell/puncta number changes.
- `mount_cellular_workspace` (called from the UI, which alone has `central_manager.selection`) builds the
  panel and wires both image tiers. New `BrushableImageTier` `reveal='overlay'` mode draws the bbox rectangle
  (robust when a layer's label values are not the row's object_id — the per-punctum case).
- `tests/test_cellular_brushable.py` (`core`, 2 tests): the global layer is unique while per-cell labels
  restart; the mount wires all six views and the cell/condensate tiers stay independent. Full `pytest -m
  core` green (1714). Phase 4 (batch) is the remaining piece.

## [1.6.283] - 2026-07-22
### Added — **In-vitro fluorescence: a brushable droplet workspace — click a droplet in the image, a plot, or the table and the others light up (Phase 2).**
The in-vitro field-summary produced a per-droplet table you could only look at — no bounding box, no
circularity, no identity, so a row could not be turned back into an image. This makes it brush-ready and
mounts the reusable workspace: two plots on the left (condensate intensity vs size, intensity vs
circularity), the per-droplet + field-statistics tables on the right, and the droplet mask as an image tier.

- New `_finalize_droplet_table` (`invitro_fluor_ui.py`): augments `per_droplet_df` **additively on a copy** —
  `area_um2`, `circularity` (4π·area/perimeter²), and the bbox, all keyed by `droplet_label` (robust to row
  order, not positional as before) — then `finalize_entity_table('condensate_analysis')` stamps
  `_pycat_entity_id` and `attach_layer_id` binds each row to the droplet mask layer. No existing droplet
  number changes.
- New `BrushableImageTier` (`ui/brushable_workspace.py`) + `BrushableWorkspace.add_image_tier`: a napari
  labels layer as a brushing tier — a click on a labeled object selects its entity everywhere, and an inbound
  selection reveals the object via the selection overlay. Two of these over one viewer = the two interleaved
  tiers the cellular case needs.
- Mounted at `invitro_fluor_ui._on_run` (falls back to the old dialog with no selection service).
  `tests/test_invitro_brushable.py` (`core`, 3 tests): the augmentation, a droplet click → correct entity,
  and the image tier passing `assert_selection_view_contract`. Full `pytest -m core` green (1712). Phases 3
  (cellular two-tier) and 4 (batch) build on this.

## [1.6.282] - 2026-07-22
### Added — **a reusable brushable results workspace (plots left, tables right) — Phase 1 of the plots+tables+image brushing feature.**
The foundation for Meet's request: one panel that stacks scatter plots down the left and results tables down
the right, every plot point and table row referring to the SAME object so clicking one lights up the others.
It owns no dispatcher — every view is a `SelectionView` on the one application `SelectionService`
(`central_manager.selection`), keyed on the stable `_pycat_entity_id`. Because a cell and a condensate are
different entity *types*, "two interleaved brushing tiers over one image" needs no special case: two views,
two keys, one service.

- New `ui/brushable_workspace.py`: `BrushablePlot` — a scatter promoted to the `SelectionView` contract
  (`comparative_figures._attach_object_brushing` as a class); a click selects the nearest object's entity
  everywhere, an inbound selection rings the point without re-emitting. Its `_object_points`/`_draw` are
  overridable, so a custom painter (VPT's MSD/moduli panels) plugs into the same brushing machinery — the
  seam by which `vpt/results_dock` can later be refactored onto this core.
- `BrushableWorkspace` — a `QSplitter` with `add_plot` (left, top→bottom) and `add_table` (right; a
  `BrushableTable`, identity columns hidden from display but kept for brushing); `detach()` unsubscribes all.
- `tests/test_brushable_workspace.py` (`core`, 5 tests): the plot passes `assert_selection_view_contract`; a
  plot click selects everywhere; a plot + table over one DataFrame brush together; two entity types are
  independent tiers on one service. Full `pytest -m core` green (1712). Phases 2–4 (in-vitro, cellular
  two-tier, batch) build on this; the image tier lands with a real labels layer in Phase 2.

## [1.6.281] - 2026-07-22
### Fixed — **the frame-0 landmine, in four more places: a lazy stack held in an `active`/`mask` variable no longer silently collapses to one frame (close_partial_specs Part C).**
`np.asarray(layer.data)` on one of PyCAT's lazy wrappers returns **frame 0 only** — nothing errors, the
analysis just runs on a single frame while reporting it as the whole movie. The guard against this only
caught variables literally named `…layer…`; four real sites held the layer in `active`/`lmask` and slipped
through:

- **Brightfield best-slice** (`brightfield_tools`) — needs a `(Z/T,H,W)` stack, but `np.asarray(active.data)`
  gave frame 0, so a correct lazy stack was rejected with *"Best-slice needs a 3D stack."* (the same class as
  the N&B "your time-series is 2D" bug). Now `materialize_stack(active.data, dtype=None)`.
- **CLEAN spot detection** (`clean_spot_detection_tools`) and the **flatfield/background corrector**
  (`ui_modules`) — both ran on frame 0 of a lazy stack. Now materialize the full stack.
- **`general_image_tools`** — the image was frame-extracted safely via `extract_2d_plane` but the paired
  mask used `np.asarray`, so a lazy mask paired frame `fi` of the image with frame 0 of the mask; the mask
  now uses `extract_2d_plane` too.
- All conversions are **output-identical on eager 2D/3D data** (`materialize_stack(dtype=None)` /
  `extract_2d_plane` return the same array `np.asarray` did) and only change behaviour on a lazy stack —
  where they fix the collapse.
- **The guard is broadened** (`test_time_series_analyses_do_not_collapse_a_lazy_stack_to_frame_zero`): it now
  flags `np.asarray(<var>.data)` for `active`/`image`/`mask`-named vars, not just `layer`, so this bug class
  is caught regardless of the variable name. `ui_diagnostics_mixin` (a diagnostic dump) stays allowlisted.
  Full `pytest -m core` green (1712). Part C's remaining ~2D-safe `asarray` sites are correct as-is.

## [1.6.280] - 2026-07-22
### Fixed — **filename-aware channel naming: a channel's name is never worse than the file the user gave it (deep_metadata_and_naming Part 3).**
`channel_naming.identify_channel` never saw the filename, so a plain 2D TIFF — which usually carries no
channel metadata at all — had its fluorophore (present only in the name, `Image1-GFP.tif`) discarded in
favour of a generic pixel guess: `Image1-GFP` and `Image1-DAPI` both became `Image1-Fluorescence`, and the
second collided into `Fluorescence(1)`. The user's own labelling — often the only channel identity a plain
TIFF has — was thrown away.

- `identify_channel` gains a `file_stem` argument and a **Tier 1c**: below real metadata (OME Fluor,
  channel name) but **above** the pixel/position guess, it matches the stem with the existing
  `_match_fluorophore_name` — so `Image1-GFP.tif` → `EGFP` (source `filename`), and `Image1-DAPI.tif` →
  `DAPI`, distinct with no `(1)` suffix.
- **Never-worse-than-input rule:** when the only thing determinable was the modality (pixels) or a bare
  position, but the stem carries distinguishing text (`_stem_distinguishing_text` skips generic/numeric
  tokens like `Image1`/`stack_001`), the user's text is kept instead of a generic modality word — the
  colormap bucket is retained so display still tracks the pixels.
- The stem is threaded from all four open paths (`image_reader_2d`, the IMS/structured/CZI-streaming
  openers). Real metadata still wins (a file named `...GFP` whose OME says `Fluor=DAPI` → DAPI), a purely
  generic stem still falls through to the pixel classification, and callers passing no stem are unchanged.
  New `tests/test_channel_naming_filename.py` (`core`, 6 tests); existing naming/extraction tests pass
  (the extraction fake's signature tracks the real one, as it did for `pixel_frame`). Full core green (1710).
  Parts 1–2 (deep per-element OME parse + reader-independent metadata merge) remain.

## [1.6.279] - 2026-07-22
### Added — **navigator wiring increment 2: the planner consults the quality gate — a plan now states when a measurement cannot be trusted, and why.**
`utils/quality_gate.evaluate_quality` composes calibration, pixel size, and reliability into a
block/warn/downgrade verdict with reasons, and had **zero consumers**. This wires it into the planner —
and does so with **no change to `compile`**: a measurement's `QualityRequirement` is turned into ordinary
`Assumption` gates on its contract, so the planner's existing machinery does the rest (it already evaluates
`step.module.assumptions` into `gate_report`, already blocks a plan on a VIOLATED **blocker** gate, and
already prepends a QC probe for an UNKNOWN gate that names one).

- New `navigator/quality_gates.py`: `quality_assumptions(requirement, op)` builds one gate per signal —
  pixel size and calibration as **blockers**, reliability as a **warning** that names the `snr` probe —
  and `gate_context(ctx)` maps an `AnalysisContext` onto the signal inputs (unasserted ⇒ unassessed, never
  a silent pass). The four verdicts fold onto the three gate states the planner already understands:
  BLOCK→VIOLATED (not runnable, reason in `blockers()`), WARN→UNKNOWN (runnable; probes if a probe is
  named), DOWNGRADE→VIOLATED on a warning gate (runnable, reported), OK→SATISFIED.
- `_measure_op_contract` attaches these gates when a measurement declares a `QualityRequirement`.
  `vpt.microrheology` now requires a pixel size (a viscosity in pixels is meaningless) and probes when
  reliability was never assessed; `partition_enrichment.client` blocks a ΔG/concentration on an invalid
  calibration. So a viscosity plan without a set scale is **not runnable and says why**, and an unassessed
  reliability **prepends `data_qc.assess`** rather than passing.
- `evaluate_quality` is unchanged; non-measurement (and measurement-without-a-QualityRequirement, e.g.
  `size`) plans are untouched. New `tests/navigator/test_quality_gate_planning.py` (`core`, 7 tests); all
  existing navigator + gate tests pass unmodified. Full `pytest -m core` green (1704 passed).

## [1.6.278] - 2026-07-22
### Added — **navigator wiring increment 1: the operation catalog now contains the ANSWERS, not just the layers.**
The catalog held only operations that produce an image or a label layer, so the planner could build a
workflow's segmentation spine but had no operation whose output is a *measurement* — it could reach a mask
and stop short of the number. This adds ten measurement operations that close the most-used question paths,
each bound to a real, already-tested function.

- New `tag_registry._register_measurement_operations()` registers ten `result`-producing ops through the
  SAME path the UI ops use, so they flow into `operation_catalog.json` on regeneration and resolve via
  `navigator.operation_spec.resolve_operation`: `region_properties`, `partition_coefficient`,
  `client_enrichment`, `size_distribution`, `spatial_statistics`, `colocalization`, `msd_diffusion`,
  `coarsening_fit`, `frap_recovery`, `viscosity`. Each names its implementation as `<module>.<function>`
  in `registered_by`, so a stale binding fails `resolve_operation` loudly (the intended guard).
- **Honest requirements, declared not gated:** the time-series measurements (MSD, coarsening, FRAP,
  viscosity) declare `time_axis`; colocalisation declares `two_channels`; viscosity also declares
  `pixel_size` (Stokes–Einstein needs a physical scale). Gating on these is increment 2 — here they are
  only declared, from the controlled `REQUIREMENTS` vocabulary so a consumer can render each reason.
- **Measurements are terminal** (`produces='result'`, consumed by nothing), so the operation graph stays
  traversable; the catalog rose 79 → 89. Data-only: no UI, planner, or scientific behaviour changed.
  Regenerated `operation_catalog.json`; new `tests/navigator/test_measurement_ops.py` (`core`) pins the set,
  its real bindings, the terminal property, and the requirement vocabulary. Full `pytest -m core` green
  (1697 passed).

## [1.6.277] - 2026-07-22
### Added — **`plot_backends.scatter(backend='auto')` auto-selects the interactive backend by size (backend_parity Part 2).**
The pyqtgraph backend, the `PyQtGraphScatterView` (a proper `SelectionView`), and the 4-backend
`plot_backends.scatter` dispatcher already existed — the missing piece was the automatic default. `scatter`
now accepts `backend='auto'`, resolving to **PyQtGraph above the point threshold where it is installed** and
**matplotlib otherwise** (via `plot_backend_selection.choose_scatter_backend`). Matplotlib stays the explicit
publication choice. Tests (`core`): a small frame resolves to a matplotlib figure with brushing enabled, and
the size decision picks pyqtgraph above the threshold when available. The Plotly widget with click-to-napari
(Part 3) remains the QtWebEngine-gated GUI follow-on.

## [1.6.276] - 2026-07-22
### Removed — **Two duplicate interaction-layer modules (correction): `utils/hit_testing.py` and `utils/selection_state.py`.**
Honest correction. In 1.6.272 / 1.6.273 I added a standalone hit-tester and `SelectionState` for the
`interaction_layer` spec **without first verifying the current tree** — which had already implemented that
spec in full, far past its 1.6.90 target: `selection_service.py` holds the canonical integrated
`SelectionState` (with `select_entity`/`toggle`/`hover`/`pin`/`clear` and back-compat), `analysis_plots.py`
already uses one `button_press_event` with nearest-curve **cycling** (a deliberately better model than the
spec's ambiguity-refusal), the MSD background is a `LineCollection` with promotion overlays, and a
`SelectionView` Protocol + contract suite exists. My two modules were pure parallel duplicates — exactly the
second-implementation the spec's own cautions forbid — and were imported by nothing. Removed them and their
tests; the `interaction_layer` spec is verified DONE in the tree. (The lesson is the discipline's own:
verify the premise against the current tree before building.)

## [1.6.275] - 2026-07-22
### Added — **Reopen a saved figure bundle to restore its refined state; brushing survives a refine (explore_refine_export complete).**
Closes the explore_refine_export follow-ons.
- `figure_refine.load_bundle(spec_json)` restores the refined `FigureSpec` from an exported bundle, and the
  raw plotted data (`<name>_data.json`) beside it when present; `FigureRefineController.from_bundle(...)`
  regenerates the EXACT refined figure from spec + data (a reproducible figure — reopening restores what was
  saved), falling back to spec-only when no raw data was written.
- Pinned that **brushing survives a refine**: a matplotlib interaction callback connected before refining is
  still connected after — refine restyles the figure, it never tears down interaction.
- Tests (`core`): bundle round-trips the refined spec and regenerates the figure (log scale honoured); a
  spec-only bundle reopens without a figure; interaction callbacks persist across refine.

## [1.6.274] - 2026-07-22
### Added — **Plot-backend decisions: interactive-scatter threshold, backend provenance, honest Plotly scope (backend_parity Parts 2–3, headless slice).**
The Qt interaction is deferred, but the decisions are small and pure — new Qt-free
`utils/plot_backend_selection.py`:
- `choose_scatter_backend(n_points, *, threshold, pyqtgraph)` — an interactive scatter defaults to
  **PyQtGraph** only above the threshold (~5000, configurable) AND where PyQtGraph is available; otherwise
  **matplotlib**, which stays the canonical publication backend regardless.
- `backend_provenance(...)` records WHICH backend rendered and why (the audit's "record which backend
  rendered").
- `plotly_interaction_scope(qtwebengine)` — Plotly advertises full click-to-napari (`'click'`) only where
  QtWebEngine exists, else `'hover_only'` (identity-bearing hover, **no dead click affordance**). Availability
  probes (`pyqtgraph_available`, `qtwebengine_available`) return a bool and never crash.
- Tests (`core`): below-threshold→matplotlib (even if PyQtGraph present), above→PyQtGraph only when
  available, configurable threshold, provenance record, honest Plotly scope. The interactive substance
  (actual PyQtGraph/Plotly rendering + selection wiring) remains the Qt follow-on.

## [1.6.273] - 2026-07-22
### Added — **Selection as a STATE: hover, multi-select, and pinning — independent and immutable (interaction_layer Gap 1).**
A selection was one object with a string mode, so there was no ctrl-click comparison set, no pinning a track
while exploring another, and no "clear the selection but keep the pins." New Qt-free
`utils/selection_state.py::SelectionState` models it as an immutable value with four independent facets —
`selected`, its `primary`, an independent `hovered`, and `pinned` — plus the `generation` counter.
- Commands (`select`/`toggle`/`hover`/`pin`/`unpin`/`clear`) each return a NEW state one generation later
  and never mutate the old one: `toggle` is ctrl-click (add/remove, primary falls back on removal), `hover`
  never disturbs `selected`, and `clear` empties selected+hovered but **KEEPS pinned** (Escape keeps pins).
  `displayed` is the union of selected + pinned (the set a view must render even outside a representative
  sample).
- Tests (`core`): select/toggle semantics, clear-keeps-pins, hover-independence, pin/unpin, one-command-one-
  generation, immutability. This pins the semantics; wiring it into `selection_service` behind the existing
  subscriber contract (back-compat preserved) is the integration follow-on.

## [1.6.272] - 2026-07-22
### Added — **Honest hit-testing for spaghetti plots — the nearest curve, or NOTHING when it's ambiguous (interaction_layer Gap 2).**
Matplotlib's per-artist picker returns whichever line it hits first — arbitrary, and in a dense tangle of
curves scientifically dishonest (a click near a crossing gives a confident answer the data can't support).
New Qt-free `utils/hit_testing.py` replaces that with one deliberate hit-test:
- `hit_test(curves, click_xy, *, tolerance_px, ambiguity_px) -> HitResult` finds the nearest curve by
  point-to-segment distance over the curves' DISPLAY-space coordinate arrays, and **refuses an ambiguous
  click** — when the best and second-best are within `ambiguity_px`, it selects NOTHING and `candidates`
  NAMES both, so the caller reports the tie instead of guessing. A miss beyond `tolerance_px` selects
  nothing too.
- `point_segment_distance` clamps the projection to the segment endpoints (a click past an end measures to
  the end, not the infinite line); `nearest_distance_to_curve` scans a polyline and skips non-finite
  vertices. Pure geometry, backend-neutral — the caller supplies display coordinates (a log-log MSD plot
  must be hit-tested in pixels, not data units).
- Tests (`core`): nearest-wins, empty→nothing, ambiguous→nothing+candidates, endpoint clamping, one click →
  at most one selection, log handled in display space. This primitive backs the matplotlib wiring (removing
  the per-line pickers) and the future pyqtgraph adapter; that wiring + the other interaction-layer gaps are
  the follow-on.

## [1.6.271] - 2026-07-22
### Added — **Comparative figures gain an Explore → Refine → Export workflow (explore_refine_export).**
The comparative-figure dialog was a plotting dialog, not a publication editor: the refinement/export
utilities existed but weren't reachable as a workflow. Now they are.
- New Qt-free `utils/figure_refine.py::FigureRefineController` — the engine over one figure: `set()` /
  `size_preset()` mutate the canonical `FigureSpec` and re-apply presentation to the SAME figure (**never
  recomputing** the comparison), and `export_bundle()` writes exactly the refined preview (**WYSIWYG** by
  construction — the same spec drives preview and export). Core-tested (`test_figure_refine.py`).
- The comparative dialog gains a **Refine row** (size preset with preview at final print width, title,
  y-scale, legend) and an **Export bundle…** button (vector PDF/SVG + high-DPI PNG + spec JSON + summary
  CSV). Refining restyles the figure without re-running the analysis; the analytical Explore mode is
  untouched (its stats title survives unless a refine title is set). `test_explore_refine_export_ui.py`
  (`integration`) drives the controls end-to-end and the export button.
- Follow-on (tracked): reopening a saved bundle to restore the refined spec, and an explicit
  brushing-survives-refine test — the analysis and export contracts are done.

## [1.6.270] - 2026-07-22
### Added — **A real comparative figure refines + exports without recomputing (publication_figures headline DoD pinned).**
`publication_figures` (the origin of the FigureSpec architecture) is complete at the module level — it
shipped through the FigureSpec merge and the publication_features tiers. This closes its **headline
Definition of Done** with a test against the ACTUAL comparative figure, not just synthetic data:
`test_publication_figures.py` builds a `condition_comparison_figure`, then
- **refines** it (title + log scale) and asserts every plotted datum (collection offsets + line ydata) is
  byte-for-byte unchanged — refinement changes presentation, never recomputes the analysis (the contract);
- **exports** the full reproducible bundle (vector PDF/SVG with embedded text fonts, high-DPI PNG, the
  regenerating spec JSON, and the summary CSV alongside).
The in-UI refinement panel (spec fields as live controls) is the one remaining piece and is tracked as the
`explore_refine_export` workflow.

## [1.6.269] - 2026-07-22
### Added — **Publication figures: legend control — placement, columns, frame (publication_features Tier 2).**
`FigureSpec.legend` adds a group→colour legend (off by default), with `legend_loc` (position),
`legend_ncol` (columns), and `legend_frame` (box on/off). The legend swatches use the semantic `color_map`
where one is set, so the legend agrees with the points. All four fields round-trip through JSON.
- Tests (`core`, Agg): no legend by default, one entry per group when on, frame + ncol honoured, swatches
  follow the colour map, round-trip.
- This closes the small remaining **Tier 2** item; what remains in `publication_features` is the Tier-3
  image-panels/scale-bars surface (GUI-verified, its own follow-on) and minor polish.

## [1.6.268] - 2026-07-22
### Added — **Publication figures: exact regeneration from the raw plotted data (publication_features Tier 3).**
A figure can now be reproduced EXACTLY, not just approximated from a summary. `render()` stashes the raw
plotted data on the figure, `export()` writes it as `<name>_data.json`, and new
`regenerate(data, spec)` reconstructs the exact figure from that data + the spec JSON — reproducibility
beyond the summary CSV.
- New `figdata_to_dict` / `figdata_from_dict` (JSON-serializable round-trip of `FigureData`) + `regenerate`.
- Tests (`core`): export → regenerate reproduces the same plotted values and honours the same spec (log
  scale, error type), and `FigureData` round-trips through its dict.

## [1.6.267] - 2026-07-22
### Added — **Publication figures: export embeds software/version metadata for reproducibility (publication_features Tier 3).**
An exported figure now records the software that made it. `export()` writes the PyCAT + key-dependency
versions into the file's metadata (PNG `Software` tEXt chunk / PDF+SVG `Creator`) and into a `_provenance`
block in the spec-JSON bundle, so a figure carries its own reproducibility record.
- New `figure_export_metadata(spec)` composes the record from `feature_provenance.software_versions()` (an
  unavailable version is omitted, never guessed).
- `spec_from_dict` now tolerates non-field keys, so the exported JSON — spec **plus** `_provenance` —
  regenerates the exact `FigureSpec` unchanged.
- Tests (`core`): the PNG carries `pycat-napari …` in its metadata, the JSON bundle carries the software
  versions, and the spec round-trips despite the extra provenance key.

## [1.6.266] - 2026-07-22
### Added — **Publication figures: semantic colour mapping + dense-scatter rasterization (publication_features Tier 3).**
- `FigureSpec.color_map` (`{group: colour}`) ties a colour to group IDENTITY, not plot position — so a
  condition keeps the same colour across every figure it appears in, however the groups are ordered.
  Unmapped groups fall back to the palette by order (unchanged default).
- `FigureSpec.rasterize_points` rasterizes the dense scatter layer inside vector output (axes and text stay
  vector), so a 50k-point figure exports as a usable PDF instead of an enormous unopenable one. Off by
  default.
- Both round-trip through JSON. Tests (`core`, Agg): a group keeps its assigned colour regardless of order,
  the scatter layer is flagged rasterized only when requested, round-trip.

## [1.6.265] - 2026-07-22
### Added — **Publication figures: validated font selection + transparent background (publication_features Tier 2).**
Two ExportSpec/legibility controls, both honouring the "validate and warn, never silently substitute" rule:
- `FigureSpec.font_family` — a chosen font is applied to the axis text, but **validated against the
  installed fonts first**: a missing font WARNS (stating that matplotlib would otherwise silently pick a
  replacement, changing the figure between machines) and falls back to the default rather than substituting
  silently. The check is a pure, tested `resolve_font_family(family)` helper.
- `FigureSpec.transparent_background` — `export()` now saves the PDF/SVG/PNG with `transparent=` honouring
  this flag (default white, unchanged).
- Both fields round-trip through JSON. Tests (`core`, Agg): installed font applied, missing font
  warns-and-falls-back, transparent flag reaches every `savefig`, round-trip.

## [1.6.264] - 2026-07-22
### Added — **Publication figures: multi-panel layout with A/B/C panel labels (publication_features Tier 2).**
The biggest structural gap the §8 audit named: the figure render was single-axis (`add_subplot(111)`), so a
multi-panel journal figure was impossible. New `render_multipanel(panels, *, spec, n_cols, panel_labels)`:
- Lays several `FigureData` out in a grid (`n_cols` columns, default as-square-as-possible) and, unless
  turned off, gives each panel a bold **A / B / C …** label in its top-left corner (Excel-style past Z:
  AA, AB, …). Each item may be a `FigureData` or a `(FigureData, FigureSpec)` pair, so a per-panel spec
  (e.g. a log axis on one panel) overrides the shared one.
- The per-axis plotting is now a shared `_render_on_axis` helper, so a panel is styled identically however
  many there are, and single-panel `render()` is unchanged (it delegates to the same helper — existing
  figure tests pass untouched). Reads the data, never recomputes it; per-panel plotted values stash on
  `fig._pycat_plotted` as a list.
- Tests (`core`, Agg): one axis per panel with A/B/C labels, `n_cols` grid respected, labels toggle off,
  per-panel spec override, single-panel grid, and labels past Z.

## [1.6.263] - 2026-07-22
### Added — **Publication figures: error/confidence representation with the type STATED (publication_features Tier 1).**
A comparison figure without a stated error is not publishable. `FigureSpec.error_type` (`'none'` | `'sd'` |
`'sem'` | `'ci95'`) draws an error bar on each group's mean and **labels the type on the figure** ("error
bars: SEM"), so an error bar is never ambiguous about whether it is SD, SEM, or a CI.
- The half-length is a pure, tested `group_error(values, error_type)`: SD (ddof=1), SEM (SD/√n), or the 95%
  CI (`1.96·SEM`); fewer than two finite values → 0 (no spread to show). Round-trips through JSON.
- Default `none` (no error bars), so a bare spec renders exactly as before.
- Tests (`core`, Agg): SD/SEM/CI computation + the two-point floor, error bars drawn only when requested, and
  the type is present as figure text.

## [1.6.262] - 2026-07-22
### Added — **Publication figures: log / symlog y-scale + minor ticks on the canonical FigureSpec (publication_features Tier 1, first slice).**
Condensate size and intensity distributions are often log-normal, so a linear axis misrepresents them — the
first Tier-1 axis controls the audit's §8 flagged as missing:
- `FigureSpec.y_scale` (`'linear'` | `'log'` | `'symlog'`), honoured by both `render()` and `refine()`, and
  `FigureSpec.minor_ticks`. Both round-trip through JSON and apply through `refine` without recomputing the
  data (the refine-not-recompute contract).
- **Validate and warn, never silently substitute:** a `log` request on data with non-positive values (it
  crosses or touches zero, which a log axis cannot show) falls back to **symlog** with a `UserWarning`
  stating the consequence, rather than silently clipping or crashing. The decision is a pure, tested
  `resolve_y_scale(y_scale, values)` helper.
- Publication-sane default preserved: a bare spec renders exactly as before (linear). Every field goes
  through the spec, so `refine`/JSON round-trip keep working.
- Tests (`core`, matplotlib Agg): `test_publication_features.py` — log is actually log, symlog honoured,
  non-positive→symlog-with-warning (render and refine), JSON round-trip, minor-ticks toggle, and refine
  leaves the plotted data untouched. Remaining Tiers (multi-panel, error representation, fonts, rasterize,
  …) ship as their own versions.

## [1.6.261] - 2026-07-22
### Added — **The Client-Enrichment UI now exposes the signal-free-region background mode (background_mode spec complete).**
`client_enrichment` has long supported three background treatments and a guardrail that refuses to let a
"background" region that is really dilute phase destroy the partition measurement — but the UI only offered
the scalar offset, so the wired guardrail could never fire from the GUI. Now it can:
- `_add_client_enrichment` gains a **"Background offset (from region)"** layer dropdown — a signal-free mask
  (OUTSIDE the cell, or a dark/blank frame) whose mean becomes the instrument offset (`background_mask`),
  overriding the scalar; its mean also feeds the per-condensate/per-cell scalar so all three agree. The
  scalar offset is relabelled and kept **visually separate** from the dilute-shell reference (conflating
  "instrument offset" and "dilute reference" is the exact error the guardrail warns about).
- Picking a region that is really dilute phase fires the **consequence-stating** warning ("subtracting it
  will subtract the dilute phase from itself and DESTROY the partition measurement…") at the moment of the
  mistake, and the chosen `background mode` / `background source` now ride in the emitted overview table so a
  reader can tell an offset-corrected K from a raw one.
- The science (the guardrail, the mode/source in the result, the ontology caveat) was already present and
  `core`-tested; this adds the UI exposure + `tests/test_background_mode_ui.py` (`integration`, end-to-end).
  **Default stays `none`** — existing results are unchanged.

## [1.6.260] - 2026-07-22
### Added — **backend_parity Part 1: a seaborn hue split gets a VERIFIED per-artist brushing map, or an honest refusal.**
When seaborn draws one artist per hue level, each artist holds a SUBSET of the table — so a table index into
one artist resolves a click to the wrong object. The prior behaviour refused brushing outright for any
multi-artist plot. This replaces the blunt refusal with a reconstruction that stays inside the exact
scientific-safety property the audit praised: **a verified mapping or an honest refusal — never a guess.**

- New `plot_backends._seaborn_subset_mappings(new_artists, df, x, y, hue)` — matches every artist to exactly
  one hue subset by the SAME point-count + coordinate check the single-artist path uses, matching by
  COORDINATES (not by assuming seaborn's artist order), and returns `(artist, row_positions)` per artist
  where `row_positions` index a 1:1 `refs` list. If any artist can't be matched to exactly one distinct
  subset, the whole plot falls back to the refusal.
- `plot_backends.scatter()`'s multi-artist branch now returns the verified mappings (`ok=True`) instead of
  refusing; a new `brushing.attach_brushing(figure, brushable, refs, **kw)` dispatches on whether the plot
  is a single artist (1:1 with `refs`) or a verified hue split (per-artist subsets), so callers do not
  special-case the split.
- **Finding (recorded in the spec):** modern seaborn (0.13.2 here) keeps a hue plot in ONE artist in
  DataFrame order, so `scatter()`'s single-artist path already brushes hue correctly and this multi-artist
  path is a verified *fallback* for seaborn versions/plots that split. Its logic is covered by
  `tests/test_seaborn_subset_brushing.py` (8 `core` tests, incl. count- and coordinate-mismatch refusals and
  a real-seaborn end-to-end "brushable, not refused" check). No scientific output changes; full core green
  (1639 passed). Parts 2–3 (PyQtGraph-default-for-large-scatter, Plotly QtWebEngine scoping) remain — both
  interactive/Qt-bound.

## [1.6.259] - 2026-07-22
### Changed — **exception_context_classification Part 2: a swallowed WRITE no longer passes as success (the write-swallow guard + the writers-first conversion).**
The scientific-result guard catches a broad `except` that returns a wrong *number*; this adds the guard for
the other silent corruption that carries **no wrong number at all** — a save that fails into apparent success.
The user clicks *Save*, the `except` eats the error, and the file is absent or truncated while everything
looks fine.

- **New `tests/test_no_silent_write_or_batch_swallowing.py` (`core`)** — an AST ratchet, conservative and
  scoped to handlers wrapped around a KNOWN persist call (`to_csv` / `savefig` / `imwrite` / `json.dump` /
  `open(..., 'w')` / `write_text` …). A broad handler there must make the failure **visible** — re-raise, or
  surface it via a notifier whose name carries `warn`/`error`/`critical` — else it is flagged unless annotated
  `# broad-ok: write — <reason>`. Treating any user notification as compliant is what keeps the many correct
  UI save handlers (`napari_show_warning("Save failed: …")`) from being false-positived. Budget pinned at **0**.
- **The writers-first conversion:** `write_session_outputs` swallowed two sidecar writes into `debug_log` — the
  acquisition-metadata JSON (provenance) and the **session manifest that Load Session reads back**. Both now
  surface via `notify.show_warning` (warn-and-continue: the result files themselves saved), so a failed
  provenance/restore write is no longer invisible. Function kept under the 120-line complexity ceiling.
- **Categorized the genuinely-safe swallows** (Part 1) with the `# broad-ok: <category> — <reason>` form:
  `local_cache`'s two best-effort cache writes and `ts_cache_manager`'s cache prune (`write` — absence costs
  nothing); `temperature_tools`' two batch-export handlers (`write`/`batch_step` — the error is recorded into
  the returned results row, visible in the output table); `channel_designations._save` (`write` — returns
  `False` so the caller surfaces the failed persist).
- **Ratchets tightened** to match: `test_exception_budget` `file_io` 239→237, `toolbox` 498→491, `utils`
  114→113. No scientific output changes. Full `pytest -m core` green (1631 passed, 2 skipped).

## [1.6.258] - 2026-07-22
### Changed — **redundancy_consolidation axis 3: the duplicated UI background-worker consolidated (behaviour-preserving).**
Four analysis panels (condensate_physics, brightfield, invitro_bf, invitro_fluor) each defined a
byte-identical `class _XWorker(QThread)` — a `finished(object)`/`error(str)` pair and a `run()` emitting
`finished(fn(**kwargs))`/`error(traceback)`. That is the audit's "duplicated worker lifecycle." It now lives
once, in `qt_worker.make_task_worker()` (a cached factory so the module still imports headless / Qt resolves
lazily). Each local class was replaced by `_XWorker = make_task_worker()`, so the local name and all 20 call
sites are unchanged and the non-modal semantics (inline spinner, GUI interactive) are byte-identical.

**Deliberately NOT routed through `operation_runner`:** those panels keep the GUI interactive with an inline
spinner, whereas `operation_runner` (via `run_with_progress`) is WINDOW-MODAL — a different UX contract.
Swapping one for the other would be a behaviour change, which is exactly what this spec forbids; the two
coexist. `_AdvancedAnalysisWorker` (progress+cancel contract) and `_SpatialWorker` (specialised) are
genuinely distinct and left separate.

Pinned by `test_qt_worker` (finished/error delivery via qtbot; one cached class). Also fixed a pre-existing
brittle assertion in `test_ui_structure` (it string-matched a now-multi-line `MenuManager` re-export; made
it AST-robust). Full core green (1536 passed).

## [1.6.257] - 2026-07-22
### Added — **MRI surfacing (step 3, batch wiring): reliability now populates automatically in exported tables.**
The reliability columns added in 1.6.255 only filled when a caller supplied a `reliability_context`; the
batch never did. Now the batch consolidation computes each image's imaging-QC reliability factor and threads
it through:

- `BatchProcessor._process_file` stashes the raw image it already loaded (`state['image']`), so QC needs no
  re-load and there is no frame-0 collapse risk.
- A new `_reliability_context_for(records, image_name)` computes `run_full_qc` on that image and passes
  `{'image_qc': ...}` to `ConsolidatedLongWriter.add_image` — but **only when the image carries a
  scored-family measurement** (partition / concentration / ΔG), gated by the new
  `records_have_scored_family`, so a non-partition batch pays no QC cost.
- Calibration is not threaded through the batch, so it stays in `missing` and the grade is **honestly
  capped** — never silently treated as passing (rule 1). The per-object biological-QC flag still
  differentiates objects, so a comparison can be recomputed on high-reliability objects.

The QC failure path is `# broad-ok: batch_step` (additive — must never drop an image from the table). The
new logic is extracted into `_reliability_context_for` rather than inlined, keeping `run()` under the
120-line ratchet (the ratchet caught the inline growth; the honest response was to split it out, not raise
the ceiling). Full core green (1536 passed).

## [1.6.256] - 2026-07-22
### Changed — **Merge Meet Raval's branch (re-targeted): large-condensate segmentation fix + pipeline progress bar.**
Two changes from Meet Raval's branch, brought onto the post-decomposition tree.

- **Large-condensate segmentation fix (re-targeted).** Meet's "Improved segmentation for large condensates"
  edited `segmentation_tools.py`, which is now a re-export shim (segmentation decomposition, 1.6.240-243) —
  his fix had been silently overwritten when he merged the decomposition into his own branch. It is
  re-applied here to where the code now lives, `toolbox/segmentation/puncta_refinement.py`, in both
  `puncta_refinement_filtering_func` and `..._fast`: objects with `area >= 150 px` are exempted from
  `local_intensity_condition` and `gradient_condition`, whose fixed 1-4 px rim scale is correct for
  point-like puncta but misfires on large condensates. All other checks (kurtosis, ellipticity,
  cell-intensity, SNR, area/solidity) still apply. Small puncta are unaffected; every science pin
  (filter-sensitivity, fast/slow parity, puncta-refinement, spurious-puncta gate, local-ring scales) passes
  unchanged. The `puncta_refinement.py` complexity ceiling was raised 715 -> 747 for the documented fix.
- **Workflow-checklist progress bar (cherry-picked).** The checklist now advances across all pipelines,
  updating even when batch recording is off; pipeline steps carry real keys; a new `timeseries_invitro_fluor`
  pipeline is added. The three checklist-notify swallows were annotated `# broad-ok: ui_cleanup` so the
  exception-budget ratchet stays at 498.

Behaviour note: large condensates (area >= 150 px) that the fixed-scale rim checks previously rejected now
survive refinement — a real behaviour change, validated by Meet on the bundled example dataset (the CI pins
exercise the small-puncta regime). Full core green (1534 passed).

## [1.6.255] - 2026-07-22
### Added — **MRI surfacing (steps 4b/4c): reliability in the consolidated table and the QC report.**
Two more surfaces for the Measurement Reliability Index (option 2 — the factors are threaded to the table
build, where image-level QC and object-level flags are both in hand):

- **Consolidated long table** gains additive `reliability` / `reliability_reasons` columns for the scored
  measurement family. The shared image factors (imaging QC, calibration validity) arrive per image in a
  `reliability_context` threaded through `build_image_long_table` and `ConsolidatedLongWriter.add_image`;
  the per-object differentiator is that object's own biological-QC flag (unflagged → 1.0, flagged → 0.5), so
  a comparison can be **recomputed on high-reliability objects only** — the scientifically strongest use.
  The columns ride at the END of the schema (purely additive, like `qc_flags`), are blank off the scored
  family, and blank entirely without a context (an unmeasured factor is never assumed passing). To score a
  single object, `_score_object_flags` now also accepts a per-object confidence float.
- **QC report** (`plot_qc_report`) gains a footer section — `reliability_report_section` — listing the
  measurements whose grade is capped below `high`, the factors that capped them, and the worst-first reason.

`_condition_fields` was updated to exclude the new fixed columns so the comparative-figure UI never treats
reliability as a condition dimension. Full core green (1534 passed). Remaining: the batch loop passing the
per-image `reliability_context` (endpoints now accept it), and extending the scored family (one entry each).

## [1.6.254] - 2026-07-22
### Added — **Measurement Reliability Index surfacing (step 4a): every scored Measurement carries its grade.**
The MRI core (`utils/reliability.py` — a decomposable 0..1 score composed from imaging QC, biological
plausibility, calibration validity, parameter sensitivity and benchmark agreement) already existed but was
unsurfaced. This wires it onto the `Measurement` value object:

- `Measurement` gains an optional `reliability: ReliabilityScore` field. `summary()` appends
  `(reliability: <grade>)` to the value line (e.g. `K_p = 4.2 (reliability: moderate)`) and lists the
  **worst-first reasons** beneath it, so the number reports WHY its reliability is what it is — never an
  opaque grade. `to_dict()` emits the grade, value, per-factor contributions, reasons and the `missing`
  factors.
- Fully backward-compatible: an unscored Measurement (`reliability=None`) reports exactly as before — no
  grade line, a null in the dict. Reliability is REPORTED, never a silent filter (the biological-QC contract).

This is the attachment point the remaining surfaces read. Still to come: attaching scores to the
partition / concentration / ΔG measurements (spec step 3), then the consolidated-table columns and the
QC-report capped-measurement section. Full core green (1530 passed).

## [1.6.253] - 2026-07-22
### Changed — **image_processing decomposition COMPLETE (step 6): image_processing_tools.py is now a pure shim.**
The last two domains moved, **verbatim**: the composite preprocessing pipeline (`pre_process_image` + its
run wrapper + the flatfield / background-subtraction shading corrections) to
`toolbox/image_processing/preprocessing.py`, and the interactive image-adjustment / upscaling wrappers
(`run_apply_rescale_intensity`, `run_invert_image`, `run_upscaling_func`) to
`toolbox/image_processing/upscaling.py` (napari stays function-scoped so it imports headless).

- **`image_processing_tools.py`: 2669 → 221 lines (-92%)** across the six steps (size_estimation 1.6.248,
  `_base` 1.6.249, deblur 1.6.250, filters 1.6.251, background 1.6.252, preprocessing + upscaling 1.6.253).
  It now contains **no function definitions** — only re-exports of the `toolbox/image_processing/` package,
  so every historical import path keeps working unchanged.
- **Every moved function had a characterization test written first** — the discipline this thinly-covered
  file demanded — pinning its exact output on fixed inputs; all passed identically before and after. Three
  registered ops moved this step → `operation_catalog.json` regenerated. This closes the
  image_processing_decomposition audit spec, the last of the four remaining god-file splits.

## [1.6.252] - 2026-07-22
### Changed — **image_processing decomposition step 5: the background/noise-removal family moves out.**
The spec's highest-downstream-impact target (background removal shapes every intensity measurement) moved
**verbatim** to `toolbox/image_processing/background.py`: rolling-ball / Gaussian background removal
(`rb_gaussian_background_removal` + inpainting / rolling-ball / subtract helpers), the edge-enhanced variant,
WBNS wavelet background+noise separation (`wbns_func` + `wavelet_bg_and_noise_calculation`), and the
realness-weighted soft foreground suppression (`_realness_weight` + `soft_foreground_suppression` +
`FOREGROUND_SUPPRESSION_DEFAULTS`, nested `_norm01`/`_soft`) — each with its viewer wrapper.

- **Characterization written first** (`test_image_processing_background_characterization`): exact output of
  all nine estimators/removers on a **known background field** (four disks on a smooth gradient) — identical
  before and after.
- Now unblocked because its `peak_and_edge_enhancement_func` dependency moved to `filters` last step. Six
  registered ops → `operation_catalog.json` regenerated. `image_processing_tools.py`: **1619 → 732**;
  ceilings ratcheted (`image_processing_tools.py` 732, `background.py` 918). Remaining: preprocessing,
  upscaling.

## [1.6.251] - 2026-07-22
### Changed — **image_processing decomposition step 4: the filter/enhancement family moves out.**
The 2D and pseudo-3D linear filters and enhancement operators moved **verbatim** to
`toolbox/image_processing/filters.py`: Gaussian smoothing, Gabor texture filtering, difference-of-Gaussian
blob enhancement, Laplacian-of-Gaussian filter/enhancement, edge-preserving bilateral filtering, and the
combined peak/edge enhancer — each with its viewer wrapper, plus the `_GABOR_KERNELS` cache and the nested
`_convolve_k` helper. They build on the `_base` primitives.

- **Characterization written first** (`test_image_processing_filters_characterization`): exact shape / dtype
  / sum / min / max of all ten filter operators on fixed synthetic inputs — identical before and after.
- Ten registered ops moved → `operation_catalog.json` regenerated. `image_processing_tools.py`: **2141 →
  1619**; ceilings ratcheted (`image_processing_tools.py` 1620, `filters.py` 564). Remaining: background
  (now unblocked — its `peak_and_edge_enhancement_func` dependency is in `filters`), preprocessing, upscaling.

## [1.6.250] - 2026-07-21
### Changed — **image_processing decomposition step 3: deblur-by-pixel-reassignment moves out.**
`deblur_by_pixel_reassignment` (DPR — sharpen by re-locating each pixel toward the local gradient on an
upsampled grid) and its `run_dpr` viewer wrapper moved **verbatim** to `toolbox/image_processing/deblur.py`.
Now unblocked because its only in-file dependency, `upscale_image_interp`, moved to `_base` in the previous
step. Pinned BEFORE the move by `test_image_processing_deblur_characterization` (exact two-array output on a
fixed synthetic scene); identical after. Registered op → `operation_catalog.json` regenerated.
`image_processing_tools.py`: **2285 → 2141**; ceiling ratcheted to 2141. Remaining: filters/enhancement,
background, preprocessing, upscaling.

## [1.6.249] - 2026-07-21
### Changed — **image_processing decomposition step 2 (foundation): the shared primitives move out.**
The algorithm families (background, preprocessing, upscaling, deblur) all reuse a handful of low-level
primitives, so — dependency-ordered, like the segmentation split — those move first, **verbatim**, into
`toolbox/image_processing/_base.py`: `apply_rescale_intensity`, `invert_image`, `upscale_image_interp` (the
three registered ops), `_safe_equalize_adapthist`, `pseudo3d_tri_planar_filter`, and the lazy-napari display
helpers `_add_image` / `_napari`.

- **Characterization written first** (`test_image_processing_base_characterization`): exact output of
  `apply_rescale_intensity`, `invert_image`, `upscale_image_interp`, `_safe_equalize_adapthist` and
  `pseudo3d_tri_planar_filter` on fixed inputs — passes identically before and after.
- napari stays function-scoped so `_base.py` imports headless. The three registered ops moved → `operation_
  catalog.json` regenerated. `image_processing_tools.py`: **2515 → 2285**; ceiling ratcheted to 2285. With
  the foundation in place the families can now import the shared primitives and move in dependency order.

## [1.6.248] - 2026-07-21
### Changed — **image_processing decomposition step 1 (characterization-first): object-size estimation moves out.**
Began decomposing `image_processing_tools.py` (2,669 lines) — the **highest-risk** big-file split (only 6
test files), so the discipline here is strict: **no characterization test, no move.** This step moves the
self-contained object-size estimators, **verbatim**:

- `estimate_object_size_px` — the headless/batch top-hat + Otsu estimator (median equivalent diameter →
  ball_radius) that feeds downstream segmentation, plus its nested `_equiv_diam` helper.
- `estimate_object_size_px_brightfield` — the experimental edge-based variant.
- `auto_object_size_valid` + `AUTO_OBJECT_SIZE_VALID_WORKFLOWS` — the workflow-validity gate.

→ `toolbox/image_processing/size_estimation.py`. A new **characterization test written first**
(`test_image_processing_size_characterization`) pins the exact `object_size_px` / `ball_radius` /
`n_objects` on a fixed synthetic scene; it passes identically before and after the move. No threshold change,
no registered ops (catalog unchanged). `image_processing_tools.py`: **2669 → 2515**; a line ceiling was
established at 2515. Remaining (dependency-ordered, shared helpers): background, preprocessing, upscaling,
deblur.

## [1.6.247] - 2026-07-21
### Changed — **timeseries decomposition COMPLETE (step 4): timeseries_condensate_tools.py is now a pure shim.**
The last two domains moved, **verbatim**: the preprocessing science (`upscale_stack_to_zarr`,
`_cellpose_min_diameter_px`) to `toolbox/timeseries/preprocessing.py`, and the Qt UI builders
(`_add_ts_upscale_stack`, `_build_ts_upscale_check_ui`, `_add_lazy_preprocess_stack`,
`_add_run_timeseries_condensate_analysis`, `_plot_condensate_fraction`, with all their nested widget
callbacks) to `toolbox/timeseries/ui.py`. napari/Qt/matplotlib stay imported LAZILY inside the builders, so
`ui.py` still imports headless (`test_ci_dependencies` / `test_no_eager_reads` green).

- **`timeseries_condensate_tools.py`: 2828 → 180 lines (-94%)** across the four steps (frame-access +
  correlation 1.6.244, analysis 1.6.245, worker plumbing 1.6.246, preprocessing + UI 1.6.247). It now
  contains **no function definitions** — only re-exports of the `toolbox/timeseries/` package, so every
  historical import path keeps working unchanged.
- `test_ui_builder_split`'s attribute contract for the two big builders was repointed to `timeseries/ui.py`,
  reference sets unchanged. No registered ops (catalog untouched).
- **Not done (deliberately):** the preprocessing *science* still embedded inside the 519-line
  `_add_lazy_preprocess_stack` builder was NOT extracted — separating it is a refactor (surgery on a Qt
  builder), not a verbatim move, so the builder moved whole to `ui.py` and the embedded-science extraction is
  left as a future refinement. This closes the timeseries_decomposition audit spec's structural goal.

## [1.6.246] - 2026-07-21
### Changed — **timeseries decomposition step 3: the QThread/ProcessPool worker plumbing moves out.**
Relocated the background-worker lifecycle to `toolbox/timeseries/execution.py`, **behaviour-preserving** (the
spec's rule: relocate, do not re-engineer threading). Moved **verbatim**:

- `_worker_read_frame` / `_process_frame_worker` — the parallel per-frame helpers that run in ProcessPool
  subprocesses.
- `_make__stackprocessworker` / `_make_timeseriesworker` — the two lazy QThread-worker factories (built on
  first *use* so **Qt/napari stay function-scoped and the module still imports headless**), together with
  their result caches and nested closures.

No threading semantics changed; the workers still run, cancel, and report as before. Qt is not imported at
module scope (`test_ci_dependencies` / `test_no_eager_reads` green). No registered ops (catalog unchanged).
`timeseries_condensate_tools.py`: **1952 → 1410**; ceiling ratcheted to 1410, `execution.py` 581. Only the
Qt UI builders remain (the last domain).

## [1.6.245] - 2026-07-21
### Changed — **timeseries decomposition step 2 (scientific-core): the analysis entry point moves out.**
The centrepiece of the time-series scientific core — `run_timeseries_condensate_analysis` (per-frame
condensate segmentation across a stack → tidy per-cell metrics DataFrame + condensate-mask stack), its
per-frame worker `_ts_analyze_frame_worker`, and the drift/metrics helpers (`_condensate_metrics_per_cell`,
`_phase_shift`, `_apply_shift`) — moved **verbatim** to `toolbox/timeseries/analysis.py`. The shared pool
initializer `_init_worker_threads` moved with it and is re-exported so the staying preprocessing worker
still resolves it.

- **"No test, no move," honoured:** `run_timeseries_condensate_analysis` had no numeric characterization, so
  `test_timeseries_analysis_characterization` was **written first** — it pins the exact output DataFrame
  (per-frame/per-cell condensate areas, counts, fractions) and condensate-mask stack on a fixed synthetic
  3-frame / 2-cell scene, on the serial path. It passes identically before and after the move.
- Threading semantics unchanged (the spec's rule: relocate, don't re-engineer). The parallel path's
  behaviour is byte-for-byte the same as before — verified against the pre-move build.
- No registered ops involved (catalog unchanged). `timeseries_condensate_tools.py`: **2600 → 1952**; ceiling
  ratcheted to 1952, `analysis.py` 685. Remaining: the Qt worker plumbing + UI builders (Qt-bound).

## [1.6.244] - 2026-07-21
### Changed — **timeseries decomposition step 1 (scientific-core): frame-access + correlation move out.**
Began decomposing `timeseries_condensate_tools.py` (2,828 lines — the largest file) into a
`toolbox/timeseries/` package, starting with the well-covered scientific pieces, **verbatim**:

- `frame_access.py` — the lazy zarr scratch dir, the source-frame reader that honours the lazy-read guard
  (no frame-0 collapse), the global intensity-range scan, and the `_ZarrStack` wrapper napari scrubs
  without dask. Pure infrastructure, no napari/Qt dependency.
- `correlation.py` — `estimate_temporal_correlation` (frame-pair sampling → regime + recommendation),
  pinned by `test_temporal_enhancement` (it recovers the seeded correlation exactly). Reads frames via
  `frame_access`.

No read/materialize semantics changed; no registered ops involved. Honouring the spec's **"no test, no
move"** rule, the analysis entry point (`run_timeseries_condensate_analysis`) is NOT moved yet — it needs a
characterization test written first, and it shares worker-init plumbing with the Qt worker; that is the
next step. `timeseries_condensate_tools.py`: **2828 → 2600**; a line ceiling was established at 2600 (it
had none before) and will ratchet down as the remaining domains move.

## [1.6.243] - 2026-07-21
### Changed — **segmentation decomposition COMPLETE (step 4): segmentation_tools.py is now a pure shim.**
The last family moved: the **subcellular** orchestrator (`segment_subcellular_objects` + its viewer wrapper
+ `_segment_core` + `compare_segmentation_speed`) moved **verbatim** to `segmentation/subcellular.py`,
importing the puncta / fz / intensity / morphology families it composes.

- **`segmentation_tools.py`: 2692 → 148 lines (-95%)** across the four steps (leaf families 1.6.240, fz +
  cellpose 1.6.241, puncta refinement 1.6.242, subcellular 1.6.243). It now contains **no function
  definitions** — only re-exports of the `toolbox/segmentation/` package, so every historical import path
  keeps working unchanged.
- Byte-identical throughout: the filter-sensitivity invariances, the fast/slow refiner parity, and the
  cellpose optional-dependency guard are all intact. Where a moved function is called or patched across a
  seam (`puncta_refinement_func`, `napari_show_warning`, `_local_ring_radii`, `_build_cellpose_model`), the
  test's patch/source-inspection target was repointed to the module the code now lives in — assertions and
  pinned numbers never changed.
- `segment_subcellular_objects` is a registered op → `operation_catalog.json` regenerated. Ceiling locked at
  148 (shim); `subcellular.py` 450. This closes the segmentation_decomposition audit spec.

## [1.6.242] - 2026-07-21
### Changed — **segmentation decomposition step 3: the puncta-refinement core moves out (byte-identical).**
The most sensitive move: the filter-sensitivity-gated **puncta refinement** family — the SNR/kurtosis/
contrast gate, the per-object local-ring background helpers, and the two bit-for-bit-identical
implementations (windowed *fast* default + original *slow* reference) behind `puncta_refinement_func` —
moved **verbatim** to `segmentation/puncta_refinement.py`. **No threshold, morphology, or operation order
changed.** Pinned by `test_filter_sensitivity`, `test_refine_fast_slow_parity`, `test_local_ring_scales`,
`test_puncta_refinement` — all green.

- The module owns the `_PYCAT_REFINE_FAST`/`_DEBUG` flags it reads, and imports `apply_watershed_labeling`
  (watershed) + `_to_uint16_safe` (_common) from their families — dependency-ordered, no cycles.
- Three tests had patch targets repointed to `puncta_refinement` (the module the moved filters now resolve
  `napari_show_warning`/`_local_ring_radii` in): `test_puncta_refinement`, `test_refine_fast_slow_parity`,
  `test_filter_sensitivity` — assertions and pinned numbers unchanged.
- `puncta_refinement_filtering_func` is a registered op → `operation_catalog.json` regenerated.
  `segmentation_tools.py`: **1239 → 566**; ceiling ratcheted to 566. Only the subcellular family remains.

## [1.6.241] - 2026-07-21
### Changed — **segmentation decomposition step 2: the fz + cellpose families move out (byte-identical).**
Continued the `segmentation_tools.py` split. Both families depend only on already-moved modules, so they
move cleanly, **verbatim** — no scale/sigma/threshold change:

- `fz.py` — `felzenszwalb_segmentation_and_merging` (+ the RAG `merge_mean_color`/`_weight_mean_color`
  callbacks) and `fz_segmentation_and_binarization`; imports `local_thresholding_func` from the
  `local_thresholding` family.
- `cellpose.py` — the optional-dependency Cellpose wrapper (`cellpose_segmentation`, with torch/cellpose
  imported LAZILY and the version-aware cyto2-vs-cpsam model build preserved exactly) plus the RandomForest
  pixel classifier + `refine_labels_with_contours`; imports `opencv_watershed_func` from `watershed`. The
  module owns the GPU/model caches it manages.

Five registered ops moved → `operation_catalog.json` regenerated. Two source-inspection tests had their
target repointed to the new module locations (`test_conditional_imports` → `cellpose.py`; the puncta-gate
guard already scans the whole package) — assertions unchanged. `segmentation_tools.py`: **2030 → 1239**;
ceiling ratcheted to 1239. Remaining families: puncta refinement, subcellular.

## [1.6.240] - 2026-07-21
### Changed — **segmentation decomposition step 1: the leaf/foundation families move out (byte-identical).**
Began decomposing `segmentation_tools.py` (2,692 lines, the best-covered large file — 41 test files) into a
`toolbox/segmentation/` package by family. This step moves the five families with no upward dependencies,
**verbatim** — no threshold, morphology, or operation-order change:

- `_common.py` — the shared `_to_uint16_safe` dtype helper.
- `local_thresholding.py` — `local_thresholding_func` (registered op) + its viewer wrapper.
- `watershed.py` — `apply_watershed_labeling` + `opencv_watershed_func` (both registered ops); imports
  `_to_uint16_safe` from `_common`.
- `morphology.py` — `cell_mask_stretching` (registered op, keeps its 50× gain ceiling).
- `intensity.py` — `compute_image_intensity_stats` + `cell_has_punctate_signal`, the RESTORED
  absolute-intensity punctate gate whose loss once caused spurious puncta (1.5.526).

The move is **dependency-ordered**: family modules import from each other, never back from `segmentation_tools`,
so there are no import cycles. Four registered ops moved → `operation_catalog.json` regenerated. The wiring
guard `test_spurious_puncta_gate` now inspects the whole segmentation surface (package + shim) so "the gate
exists and is wired in" is checked wherever the code lives — same assertions, stronger guarantee.
`segmentation_tools.py`: **2692 → 2030**; ceiling ratcheted to 2030. Remaining families: fz, cellpose,
puncta, subcellular.

## [1.6.239] - 2026-07-21
### Changed — **vpt decomposition COMPLETE (steps 5-6): vpt_tools.py is now a pure re-export shim.**
The last two domains moved out **verbatim**: bead-population routing (`split_bead_populations`,
`select_bead_population`, `aggregate_population_stats`) to `toolbox/vpt/populations.py`, and the
`run_vpt_analysis` orchestrator (+ `_link` linker dispatch, `compare_detection_variants` sweep) to
`toolbox/vpt/analysis.py`.

- **`vpt_tools.py`: 2834 → 95 lines (-97%)** across the six steps (viscosity 1.6.235, drift 1.6.236,
  host 1.6.237, detection 1.6.238, populations + analysis 1.6.239). It now contains **no function
  definitions** — only re-exports of the `toolbox/vpt/` package, so every historical import path
  (`from pycat.toolbox.vpt_tools import …`) keeps working unchanged.
- Byte-identical throughout: the golden-master MSD→D→viscosity chain (~8.325 on the real bead file) and
  the GPU / CPU-parallel equivalence guards are the net, all green. Revert condition unchanged: any drift
  in detect_beads_stack's output/order or the viscosity number.
- Ceilings ratcheted: `vpt_tools.py` → 95 (locked at shim size); `vpt/detection.py` 1773, `vpt/analysis.py`
  228 get their own concentration ceilings. This closes the vpt_decomposition audit spec.

## [1.6.238] - 2026-07-21
### Changed — **vpt decomposition step 4: the entire bead-detection stack moves out (byte-identical).**
The whole detection domain — LoG blob detection (CPU + GPU), Airy/template PSF scoring, hot-pixel masking,
ring-merge dedup, bead classification, the `detect_beads_stack` orchestrator with its GPU/CPU-parallel
backend chooser, and the two linking-condition probes (`assess_linking_conditions`,
`estimate_linking_distance_um`) — moved **verbatim** (1754 lines) into `toolbox/vpt/detection.py`.

- **Not a single detection or its order changed** — this is the validated detection path and downstream
  linking is order-sensitive (revert condition: any change to detect_beads_stack's output or the parallel /
  GPU equivalence guards). The linking probes are folded in because they *run* detection and are coupled to
  it. `vpt_tools.py` re-exports every public entry point plus the two private helpers the
  parallel-equivalence test imports.
- `detect_beads_stack` is a registered navigator op → `operation_catalog.json` regenerated (its source
  updated to `vpt/detection.py`).
- Two VPT tests had their module alias repointed from `vpt_tools` to `vpt.detection` so their
  `monkeypatch.setattr` targets bind the module the internal bare-name calls now resolve against
  (`test_vpt_pool_gate`, `test_vpt_equivalence_guard_memo`) — assertions and pinned numbers unchanged.
- `vpt_tools.py` dropped **2155 -> 405** lines (-81%); the per-file ceiling ratcheted to 405, and
  `detection.py` got its own ceiling at 1773. Only population routing + `run_vpt_analysis` remain.

## [1.6.237] - 2026-07-21
### Changed — **vpt decomposition step 3: the host-condensate domain moves out (byte-identical).**
`segment_host_condensate`, `erode_host_mask`, and `infer_host_from_beads` (the Mode-C host reconstruction
from the bead distribution) moved **verbatim** into `toolbox/vpt/host.py`. `vpt_tools.py` re-exports the
three; no number changed, VPT tests pass unmodified.

- The host segmentation ops are registered navigator operations, so `operation_catalog.json` was
  regenerated (their catalog source updated to `vpt/host.py`).
- `vpt_tools.py` dropped **2429 -> 2155** lines; the per-file ceiling ratcheted to 2155. Moved keys in the
  drop-guard's `_DELIBERATE`. Remaining: detection, linking, analysis.

## [1.6.236] - 2026-07-21
### Changed — **vpt decomposition step 2: the ensemble drift-correction domain moves out (byte-identical).**
`drift_correct_com` (subtracts common-mode stage/sample drift so it is not read as bead diffusion) and
`reclassify_by_temporal_stability` moved **verbatim** into `toolbox/vpt/drift.py`. `vpt_tools.py`
re-exports both; no number changed, drift tests pass unmodified.

- `drift_correct_com` is a registered navigator operation, so its catalog source updated from
  `vpt_tools.py` to `vpt/drift.py` — the generated `operation_catalog.json` was regenerated (2 lines).
- `vpt_tools.py` dropped **2585 -> 2429** lines; the per-file ceiling ratcheted to 2429. Moved keys in the
  drop-guard's `_DELIBERATE`.

## [1.6.235] - 2026-07-21
### Changed — **vpt decomposition step 1: the Stokes-Einstein viscosity domain moves to its own module (byte-identical).**
Begins decomposing the 2,834-line `vpt_tools.py` by domain. `viscosity_measurement`,
`viscosity_from_diffusion`, `viscosity_interval_from_diffusion` (and the `_K_BOLTZMANN` constant) moved
**verbatim** into the new `toolbox/vpt/viscosity.py`, alongside the existing VPT adapters.

- **`vpt_tools.py` re-exports** all three for every caller (vpt_ui, the viscosity-chain tests). Move, not
  rewrite — no number changed.
- **Byte-identical:** the golden-master viscosity chain (viscosity to 3.2%) and the route-equivalence tests
  pass unmodified; moved keys recorded in the drop-guard's `_DELIBERATE`.
- `vpt_tools.py` dropped **2834 -> 2585** lines; a new per-file ceiling established there that ratchets down
  as the remaining domains (detection, host, linking, drift, analysis) move out.

## [1.6.234] - 2026-07-21
### Fixed — **Three CI-hygiene / test-fixture correctness fixes (ci_hygiene_fixes).**
- **Stale CI coverage comment corrected.** `core.yml` claimed the `core` marker "selects only the two guard
  files, so coverage would be near-zero" — a fossil. Measured: the suite (~1,500 tests, 200+ marked files)
  covers **30% of pycat**. The comment now states the real number; coverage stays off in CI for a real
  reason (adds ~70% runtime with no report consumer), with the local command recorded.
- **tifffile fixtures pinned to `minisblack`.** Two `imwrite` calls in `test_lazy_sources_headless.py` wrote
  `(4,H,W)`/`(5,H,W)` arrays with no `photometric`, which a future tifffile default change could reinterpret
  as RGBA planes — a latent test time-bomb. Now explicit grayscale (verified under `-W error`).
- **`pywt` import deferred to function scope.** `from pywt import wavedecn, waverecn` moved out of
  `image_processing_tools` module scope into the one wavelet function that uses it, so the 8 toolbox modules
  that transitively import it no longer load PyWavelets on import (verified). Behaviour-identical.

## [1.6.233] - 2026-07-21
### Fixed — **A batch image whose consolidated-table append fails is no longer reported as a clean success (silent cohort corruption).**
`exception_context_classification` spec, increment 1 — the batch_step category and its concrete offender.
- **The bug:** `BatchWorker.run` marked an image `✓` even when adding its rows to `consolidated_long.csv`
  failed — so that image's rows silently vanished from the consolidated cohort while the batch reported
  success. A 93-of-100 cohort that looks complete is a silent scientific corruption. The success mark is now
  **gated** on the consolidated append succeeding; on failure the image gets a visible `⚠` partial status
  ("processed, but NOT added to the consolidated table — its rows are MISSING…"), so the drop is actionable.
  The per-image output folder is still complete; only the consolidated aggregation is flagged.
- **The category vocabulary:** `test_exception_budget.py` now recognizes the five handler categories
  (`ui_cleanup` / `optional_probe` / `scientific_result` / `write` / `batch_step`) and validates the category
  **when** a handler uses the `# broad-ok: <category> — <reason>` form — a typo'd category (a `write` that
  reads `writes`) fails, so it can't silently escape its standard. Legacy plain `# broad-ok: <reason>`
  handlers are unaffected; the batch handler above is annotated `# broad-ok: batch_step — …`.
- Tests (`core`): `test_batch_step_visibility.py` (AST-guards the gated success + visible partial — the loop
  is a QThread) and a category-validation case in `test_exception_budget.py`. **Follow-on** (recorded in the
  spec): the full ~166-handler category sweep, the writer-scoped swallow guard, and writer conversions.

## [1.6.232] - 2026-07-21
### Added — **A general object-quality gate (backlog A2, part 1) — block / warn / downgrade, with reasons.**
Every measurement has preconditions (a physical-unit result needs a real pixel size; a concentration needs a
valid calibration; a trusted number needs its reliability assessed), and those signals already exist — but
nothing composed them into one answer a navigator, batch run, QC advisor, and measurement UI could all
consult. New `utils/quality_gate.py` is that composer — **feature-agnostic, and it invents no new metric**;
it only combines `pixel_size`, `calibration.check_calibration_validity`, and `reliability`.
- `evaluate_quality(objects, requirement, *, context) -> GateResult`, where `QualityRequirement` declares
  what an op needs (`needs_pixel_size`, `needs_calibration`, `min_reliability`) and `context` supplies the
  signal inputs. Only the requested signals are consulted; the op that needs nothing is OK.
- Enforces the "refuse rather than lie" rules: **BLOCK ≠ WARN ≠ DOWNGRADE** (do-not-run / caveat-attached /
  reduced-claim); **an unassessed signal is never a silent pass** (it WARNs); **report, don't drop** — every
  signal's outcome is on the result and the overall verdict is the WORST of them (`runnable` is false only
  on BLOCK).
- Tests (`core`, `test_quality_gate.py`): missing pixel size blocks; unassessed pixel size / absent
  calibration warn; invalid calibration blocks and a warn-level warns; an unreliable grade downgrades; a
  below-floor grade warns; NaN reliability warns; the worst signal wins with all reported; and it composes
  the real `reliability` signal from raw inputs. **Remaining A2:** binding the measurement ops into
  `operation_catalog.json` with a `QualityRequirement` each, so the planner emits a measurement step as
  runnable only when the gate passes — the heavier, catalog-wiring half.

## [1.6.231] - 2026-07-21
### Added — **App mode + feature registry (backlog A3 substrate) — beginner/advanced, and the catalogue of "what can PyCAT do."**
The non-Qt foundation for the navigator/beginner-mode work, both consuming the 1.6.230 user-settings store
and both `core`-tested. The heavy beginner-home Qt dock is the remaining A3 piece.
- `utils/app_mode.py`: `AppMode` (BEGINNER/ADVANCED) persisted at `app.mode` — **first run is BEGINNER**,
  every later run is the last choice. `current_mode`/`set_mode`/`is_beginner`/`is_advanced`/`toggle_mode`
  and `on_mode_change(cb)` (fires on change). Owns no widget; any UI consults it. An unrecognized stored
  value degrades to BEGINNER. The store is injectable, so it tests without touching the real config.
- `utils/feature_registry.py`: `FeatureCard` (key, title, one-line summary, category, opaque `entry`
  callable, docs anchor, `min_mode`) + `FeatureRegistry` — the single answer to "what can PyCAT do, and
  where." A duplicate key is refused (two features can't claim one id); `visible_in(mode)` / `visible_now()`
  filter by app mode (a beginner sees the beginner set, an advanced user sees everything); `by_category`
  groups them. A **catalogue, not a launcher** — it never invokes `entry`, so it stays Qt-free. A future
  capability becomes discoverable the moment it registers one card.
- Tests (`core`): `test_app_mode.py` (first-run beginner, persistence, toggle, degrade, change-notify) and
  `test_feature_registry.py` (register/retrieve, duplicate refused, mode-visibility, category grouping).

## [1.6.230] - 2026-07-21
### Added — **Process-wide user settings (backlog A1) — one corruption-safe, atomic home for cross-session preferences.**
PyCAT had nowhere to remember a choice between sessions (no `QSettings`, no user-config file, no first-run
flag), so the navigator mode, acquisition profiles, plot-backend preference, and dismissed QC warnings had
nowhere to live. New `utils/user_settings.py` is that home — a general mechanism with **zero feature
knowledge**; each feature registers its own defaults and reads/writes its own namespaced keys.
- `UserSettings`: namespaced key/value store persisted to one JSON file in the OS user-config dir
  (`platformdirs.user_config_dir('pycat')`, home-dir fallback). Registered defaults (a stored value always
  wins), typed accessors (`get_bool`/`get_int`/`get_float`/`get_str`) that **coerce and fall back rather
  than raise**, `set`/`reset`, and `subscribe(key, cb)` firing only on an actual change. Process-wide
  instance via `settings()`.
- **Two startup-critical guarantees:** a corrupt settings file **never crashes** — it falls back to
  defaults and the bad file is quarantined (`.corrupt`), not deleted; and a write is **atomic** (temp file +
  `os.replace` + `fsync`), with `set()` transactional — a mid-write failure rolls back the in-memory change
  and leaves the previous good file intact.
- Tests (`core`, `test_user_settings.py`): cross-session round-trip, default resolution, typed coercion +
  fallback, change-only subscriptions, corruption-safety + quarantine, write atomicity, namespace
  non-collision. Unblocks the navigator/beginner-mode work (A3) and is reusable everywhere.

## [1.6.229] - 2026-07-21
### Added — **Spectral / bleed-through unmixing is now a UI step — the C1 capability is user-reachable.**
The linear unmixing shipped in 1.6.226 was API-only; it now has a toolbox step, so it is no longer an
invisible capability. **Menu:** Image Processing → Background and Noise Correction → "Spectral /
Bleed-through Unmixing (2–4 channels)"; it also appears in the exploratory workbench's Image Processing
section.
- New `_ImageOpsWidgetsMixin._add_run_spectral_unmixing`: pick a single-label CONTROL layer per channel
  (each a sample with only that fluorophore, imaged in all channels) + the mixed multi-channel image + a
  background offset, and Run. The **thin handler only reads layers and shows results** — the matrix
  estimation, the singular-matrix refusal, the unmix and the negative-fraction honesty check are all the
  `core`-tested `unmixing_tools`. Unmixed channels are added as `Unmixed C0…` layers; a high negative
  fraction and any matrix-plausibility warnings surface as napari messages; a refusal is shown, not crashed.
- Tests (`integration`, `test_unmixing_ui.py`): the step builds, is driven end-to-end (dropdowns → Run) and
  **recovers the true channel abundances** from a synthetic mixture; and with controls missing it warns and
  emits nothing. `menu_manager` / `ui_modules` held exactly at their line ratchets (net-zero registrations).

## [1.6.228] - 2026-07-21
### Added — **Typed result models (backlog D3) — a result crossing a boundary is a TYPE, not a bare dict.**
New `utils/result_models.py`: two frozen envelopes that formalize the results PyCAT passes between modules
(brushing, batch replay, plotting, export) as free-form dicts today — where a renamed key is a runtime
surprise, not a type error. **They compose existing types; they invent nothing.**
- `AnalysisResult` (operation_id, entity_type, source_layer_ids, measurements table, artifacts, provenance,
  calibration) — carries the `feature_provenance.FeatureProvenance` / `calibration.CalibrationCurve` PyCAT
  already produces. **Validates at construction:** non-empty ids, and `measurements` must be a DataFrame or
  None — never a bare dict (that is the drift the envelope exists to stop).
- `BatchStepResult` (status, outputs, warnings, error) — `status` is one of `ok/warning/error/skipped`, and
  `error` is a **typed `PyCATError`, never a string**. Status and error must AGREE: you cannot build a step
  marked `error` with no error, or one carrying an error but claiming success.
- Both are **frozen**; sequences coerce to tuples. `to_dict`/`from_dict` bridge to the dict form existing
  code speaks, so a producer can emit the typed object while a not-yet-migrated consumer still reads a dict
  (incremental, non-breaking adoption). The rich composed fields cross the serialization boundary in their
  dict form; the in-memory model holds the real typed objects.
- Tests (`core`, `test_result_models.py`): frozen + validation (unknown status, failed-step-without-error,
  stringly-typed error, measurements bare-dict all refused), dict-boundary round-trip, composed-dataclass
  serialization. Migrating the first consumers (brushing, batch, export) behind `to_dict`/`from_dict` is the
  follow-on — nothing breaks meanwhile.

## [1.6.227] - 2026-07-21
### Added — **A first-class biological object graph, increment 1 (backlog C2) — objects + parent/child, read-only.**
New `utils/object_graph.py`: every detected object as a persistent identity with a graph over it, instead of
a mask label plus a disconnected DataFrame row. **Reuses the existing identity — no parallel id scheme:** a
`BiologicalObject` is keyed on its `_pycat_entity_id` value (the canonical `EntityKey` string). **Read-only
view** assembled from tables PyCAT already produces — it changes no table and re-runs no analysis.
- `BiologicalObject` (key, entity_type, measurements, qc_flags, provenance, parent, children) +
  `ObjectGraph` with `get`/`parent_of`/`children_of`/`descendants`/`ancestors`/`roots`/`unrooted`/`of_type`/
  `filter` (cycle-guarded walks).
- `objects_from_table(df, entity_type, *, id_col, parent_id_col, measurement_cols, qc_col)` builds objects
  from one table; `build_object_graph(objects)` resolves the parent/child edges at construction.
- **Honest structure:** a flat table (no parent info) → a flat graph of roots; an object naming a parent NOT
  present → an explicit `unrooted` (orphan) bucket, never silently dropped or silently rerooted; roots and
  orphans are distinct. Rows without a stable id are skipped.
- Tests (`core`, `test_object_graph.py`): id-keyed objects + measurements, flat→roots, parent/child tree,
  descendants/ancestors, missing-parent→unrooted, of_type/filter, source table never mutated.
- Increment 1 is record + graph only. The schema-specific join that derives a punctum's parent-cell id from
  the cell-labelled-puncta convention, and the linked-navigation/state-vector vision, are later increments.

## [1.6.226] - 2026-07-21
### Added — **Linear spectral / bleed-through unmixing (backlog C1) — from controls, refusing to invert garbage.**
New `toolbox/unmixing_tools.py`: the general 2–4 channel form of the single-coefficient bleed-through knob
in `ratiometric_tools` (it complements it, does not replace it). Models each observed channel as a fixed
linear combination of the true fluorophore abundances, `c = M·a`, and recovers `a = M⁻¹·c`, with the science
in the rules — the same "refuse rather than lie" contract as the calibration and pixel-size gates:
- `estimate_mixing_matrix(single_label_controls, *, background)` — the mixing matrix comes from single-label
  CONTROLS, never the mixed data (that would be circular); background is subtracted BEFORE the crosstalk
  ratio is formed; a control dark in its own channel is refused (you cannot normalize by zero).
- `unmix(channels, M, *, background, condition_limit)` — **refuses a singular / ill-conditioned matrix**
  (`cond(M) > 1e6`) with a stated reason rather than pseudo-inverting it and amplifying noise into confident
  nonsense.
- `negative_fraction(unmixed)` — the built-in honesty check (a large negative fraction means the linear
  model is wrong); `clip_for_display` clips negatives **for display only**, never back into a measurement;
  `mixing_matrix_warnings` flags a likely swapped channel assignment or an over-subtracted background.
- Linear 2–4 channel crosstalk only — NOT a lambda-stack spectral unmix. Pure numpy, `core`-tested against
  synthetic mixtures with a known matrix (`test_unmixing.py`: control-based estimation, background removed
  first, mix→unmix round-trip, singular-matrix refusal, the negative-fraction honesty check).
- **Follow-on (tracked, not silent):** a toolbox UI step to select the controls + mixed image is not wired
  yet — the capability is usable via the API / batch today; UI surfacing waits on the feature-registry work.

## [1.6.225] - 2026-07-21
### Added — **The batch consolidated table now exports a provenance sidecar — wiring the orphaned `feature_provenance` export hook (backlog B2 complete).**
`feature_provenance.write_provenance_sidecar` was built and tested but had no caller, so a consolidated
table left no machine-readable record of what its features are or what software produced them. Now wired at
the batch export:
- `ConsolidatedLongWriter` tracks the feature vocabulary it writes and gains
  `write_provenance_sidecar()`, which emits `consolidated_long_provenance.json` beside the CSV — one entry
  per feature present, each carrying the software versions (filled automatically) and, for features in the
  measurement ontology, their units + definition. Nothing is fabricated: an unknown feature gets no guessed
  unit. No-op when no measurements were written.
- `batch_processor` calls it once after the consolidated table is finalized (additive, best-effort — a
  sidecar failure never fails the batch).
- Tests (`core`): the sidecar is written beside the table keyed by feature, ontology features carry
  units+definition while unknown ones do not, and the empty case writes nothing.
- **This completes backlog B2** (wire the actionable orphans): `condensate_modes` (1.6.223), `czi_seam`
  (1.6.224), and `feature_provenance` are now wired; `cohort_targets`'s histogram-brushing hook was already
  wired in the feature-explorer dock.

## [1.6.224] - 2026-07-21
### Added — **CZI opens now run a non-blocking mosaic-seam QC — wiring the orphaned `czi_seam` module (backlog B2).**
`file_io/czi_seam.py` (the pure, tested seam metric) was imported by nothing, so a returning tile-assembly
seam could go unnoticed. The streaming CZI open path now warns if one is present:
- New pure helpers in `czi_seam.py`: `sample_frame_indices` (a handful of evenly-spaced frames, never the
  whole movie), `seam_qc_message` (a one-line warning if the sampled frames share a persistent vertical
  seam, else None), `seam_qc_from_lazy_stack` (samples by **per-frame indexing** — `np.asarray` on the whole
  lazy stack is refused by design — row-subsamples each plane to stay cheap, needs ≥2 frames, degrades to
  None on any read/shape problem), and a thin `warn_seam_qc(stack, show_warning)` wrapper.
- `stack_openers._open_czi_streaming` calls `warn_seam_qc(lazy, napari_show_warning)` once per file
  (channel 0). **Best-effort and non-blocking:** a QC failure never breaks the open, and it reads a handful
  of planes, not the movie. It measures the seam; it does not fix or refuse the load.
- Tests (`core`): `test_czi_seam.py` gains the frame-sampling, message, lazy-stack (flags an injected
  persistent seam, clean → None, degrades safely, needs multiple frames), and an AST guard that the
  streaming loader wires it. No pixel/measurement changes.

## [1.6.223] - 2026-07-21
### Added — **The in-vitro field-summary tables now carry their `condensate_mode` — wiring the orphaned `condensate_modes` module (backlog B2).**
`toolbox/condensate_modes.py` was shipped and unit-tested but imported by nothing — so the "**2D
projection, not a volume fraction**" caveat lived only in a transient napari message while the number
travelled on into tables, the consolidated long table, and comparative figures with no qualifier attached.
Now wired at both in-vitro whole-field-summary emission points (fluorescence and brightfield):
- New pure `condensate_modes.annotate_summary_table(table, masks)` resolves the mode from the mask, adds a
  `condensate_mode` column, and — where a volume fraction is refused for that mode (a 2D field, or a time
  series with no Z) — adds a `volume_fraction_note` stating why. **Purely additive:** existing columns are
  untouched, so the byte-identical `field_summary` dict is never modified (its guard test still passes).
- `invitro_fluor_ui` and `invitro_bf_ui` each call it in one line after building `summ_df`, so the
  projection qualifier now rides IN the emitted/saved table instead of evaporating with the info message.
- Tests (`core`): `test_condensate_modes.py` gains the additive-annotation test (2D → mode `2d` + refusal
  note, originals untouched), the 3D case (mode `3d`, no refusal note — the volume fraction is real there),
  and an AST guard that both in-vitro handlers call `annotate_summary_table`. No measurement changes.

## [1.6.222] - 2026-07-21
### Fixed — **Status markers (the "logic gate" circles) now tell the truth: GREEN means DONE, not "ready".**
A tester found four ways the workflow status circles lied about readiness — and a lying marker undermines
the whole anti-black-box promise. All four are fixed in the SHARED mechanisms, so the fix applies to every
pipeline that uses them, not just the fluorescence one the tester used.
- **Green now means the step RAN, not "could run" (the systemic bug).** `button_with_circle` used the same
  solid green for "inputs satisfied" (ready) and "action completed" (done). Readiness now renders as a
  distinct **outlined amber ring** — it can never be misread as the solid-green done. The colour decision
  moved into a new Qt-free `utils/marker_logic.py::resolve_marker` (unit-tested headlessly): precedence
  done → ready → resting; `filled=False` only for ready.
- **Measure-line completes on Measure, not Draw.** The Draw→Measure→Clear line control is one cycling
  button, and the marker greened on *any* click — so it went green on the Draw click. `button_with_circle`
  gained `complete_on_click=False` + a `mark_done()` hook; the measure widget now marks done only from the
  Measure phase, success-gated on a real measurement (`_diameter_measured`).
- **Select-image labels turn green on a valid layer.** In `_layer_row`, a real (non-placeholder) layer
  that didn't match the dropdown's `name_hint` and wasn't manually picked (auto-defaulted) fell through to
  **red** — so a valid selection read as "nothing selected." A valid selection is now green (the hint is a
  suggestion, not a requirement); placeholder still reverts to red/yellow. Fixes all four selectors the
  tester named (they share `_layer_row`).
- **Optional-step colour convention decided and made explicit (kept blue).** Blue = "an optional step you
  completed" carries real information, so it stays — but its meaning is now stated in the tooltip and the
  `field_status` / `marker_logic` docstrings, applied uniformly to every optional action (e.g. upscaling),
  not left ad hoc.
- Tests: `tests/test_marker_logic.py` (`core`, Qt-free) pins the decision — ready is never solid green, a
  not-done marker is never solid green, done beats ready, only ready is outlined;
  `tests/test_status_markers.py` (`integration`) pins the widget wiring (ready-ring→green on run, cycling
  button no-green-on-click, optional→blue). No measurement or output changes — UX correctness only.

## [1.6.221] - 2026-07-21
### Changed — **condensate_physics decomposition COMPLETE: the tools file is now a pure re-export shim (byte-identical).**
The final domain — microrheology **moduli** (`per_track_msd_curves`, GSER + Evans G'/G'' estimators,
`extract_fusion_relaxation`) — moved **verbatim** into `condensate_physics/moduli.py` (it imports the MSD
path from the sibling `msd` module). No modulus number changed.

- **`condensate_physics_tools.py` is now a 122-line pure re-export shim** (from 2470, -95%) over the
  `toolbox/condensate_physics/` package: `msd.py`, `moduli.py`, `coarsening.py`, `relaxation.py`,
  `photobleaching.py`, `frame_quality.py`, `intensity.py`, `survival.py`. Every previously-public name is
  re-exported, so all callers (VPT, timeseries, dynamics UIs, navigator, trackmate) import unchanged; the
  now-unused module-level imports were removed from the shim.
- **Byte-identical across the whole decomposition** — the golden-master MSD->D->viscosity chain and every
  physics characterization test pass unmodified; moved keys recorded in the drop-guard's `_DELIBERATE`; the
  per-file ceiling ratcheted 2470 -> 122. Full `pytest -m core` green.

## [1.6.220] - 2026-07-21
### Changed — **condensate_physics decomposition step 4: the MSD / anomalous-diffusion domain moves out (byte-identical).**
The largest, most-checked domain — the MSD→D→viscosity chain the golden-master pins — moved **verbatim** into
`condensate_physics/msd.py`: `compute_msd`, `fit_anomalous_diffusion`, `msd_per_track`, `test_confinement`,
their `_short_track_rejections`/`_confined_msd`/`_lag_window_gate`/… helpers, and the
`MIN_TRACK_LENGTH_FRAMES` constant.

- **The tools module re-exports** the public entry points + `MIN_TRACK_LENGTH_FRAMES` + the constants/helpers
  the diagnostic tests read, so every caller (VPT, timeseries, dynamics UIs, `analysis_presets`) and the
  still-in-tools moduli functions (which call `compute_msd`) are unchanged.
- **Byte-identical — the golden-master MSD→D→viscosity chain (D to 1.1%, α to 0.1%, viscosity to 3.2%)
  passes unmodified**, as do the min-track-length diagnostics; two monkeypatch targets were updated to follow
  the moved symbols (assertions unchanged). Moved keys recorded in the drop-guard's `_DELIBERATE`.
- `condensate_physics_tools.py` dropped **1479 -> 602** lines; the per-file ceiling ratcheted to 602. Only
  the moduli section remains before the shim.

## [1.6.219] - 2026-07-21
### Changed — **condensate_physics decomposition step 3: three leaf domains move out (byte-identical).**
Three independent domains moved verbatim into the `condensate_physics/` package: `intensity.py`
(`fit_bimodal_intensity`, `intensity_decomposition_per_cell`), `survival.py` (`kaplan_meier_lifetimes`),
and `relaxation.py` (`fit_aspect_ratio_relaxation`). The tools module re-exports all four for every caller.

- Move, not rewrite — no fit or number changed; the intensity/survival/fusion tests pass unmodified. Moved
  keys recorded in the drop-guard's `_DELIBERATE`; two cross-module helper imports (`_bbox_cols`,
  `assess_fit`) carried into the new modules (caught by the undefined-name guard).
- `condensate_physics_tools.py` dropped **1802 -> 1479** lines; the per-file ceiling ratcheted to 1479.
  Remaining: MSD (the golden-master net) + moduli, then the shim.

## [1.6.218] - 2026-07-21
### Changed — **condensate_physics decomposition step 2: photobleaching + frame-quality domains move out (byte-identical).**
Two coupled domains — `analyse_frame_quality` calls `fit_photobleaching` — moved together into
`toolbox/condensate_physics/photobleaching.py` (`fit_photobleaching`, `apply_bleach_correction` + helpers)
and `frame_quality.py` (`analyse_frame_quality`, `detect_out_of_focus` + entropy/gradient/trend helpers).

- **The tools module re-exports all four** for every caller (condensate-physics/invitro/general-image UIs,
  the file_io stack-access probes). Move, not rewrite — no number changed.
- **Byte-identical:** the photobleaching characterization/window tests and the focus/debris tests pass; two
  test targets were updated to follow the moved symbols (a monkeypatch and a source-inspection module),
  assertions unchanged. Moved keys recorded in the drop-guard's `_DELIBERATE`.
- `condensate_physics_tools.py` dropped **2242 -> 1802** lines; the per-file ceiling ratcheted to 1802.

## [1.6.217] - 2026-07-21
### Changed — **condensate_physics decomposition step 1: the coarsening domain moves to its own module (byte-identical).**
Begins decomposing the 2,443-line `condensate_physics_tools.py` by physical quantity. `fit_coarsening` and
its phase helpers (`_coarsening_powerlaw_fits`, `_coarsening_is_arrested`, `_coarsening_confidence`) moved
**verbatim** into the new `toolbox/condensate_physics/coarsening.py`.

- **The tools module re-exports `fit_coarsening`** for every caller (condensate-physics/invitro/brightfield
  UIs, the navigator op-catalog, trackmate bridge). Move, not rewrite — no fit or number changed.
- **Byte-identical:** `test_fit_coarsening_output_is_byte_identical` and the arrest-classification tests
  pass unmodified; the moved nested keys are recorded in the drop-guard's `_DELIBERATE`.
- `condensate_physics_tools.py` dropped **2447 → 2242** lines; a new per-file ceiling established there that
  ratchets down as the remaining quantities (MSD, moduli, relaxation, frame-quality) move out.

## [1.6.216] - 2026-07-21
### Changed — **invitro decomposition COMPLETE: `invitro_tools.py` is now a pure re-export shim (byte-identical).**
The final domains — coarsening kinetics (`coarsening_statistics`), critical-concentration estimation
(`estimate_csat_lever_rule`), contact-angle geometry (`estimate_contact_angle`), fusion detection
(`detect_and_fit_fusions`), and sedimentation correction (`detect_sedimentation`) — moved **verbatim** into
`toolbox/invitro/analysis.py`.

- **`invitro_tools.py` is now an 88-line re-export shim** (from 2051, −96%) over the `toolbox/invitro/`
  package: `size_distribution.py`, `partition.py`, `field_summary.py`, `analysis.py`. Every previously-public
  name is re-exported, so all callers (invitro UIs, batch steps, timeseries, op-catalog) import unchanged.
- **Byte-identical:** no fit, background, threshold, or reported number changed across the whole
  decomposition; the existing in-vitro tests are the net. Moved keys recorded in the drop-guard's
  `_DELIBERATE`; the per-file ceiling ratcheted 2051 → 88. Full `pytest -m core` green.

## [1.6.215] - 2026-07-21
### Changed — **invitro decomposition step 3: the whole-field summary moves to its own module (byte-identical).**
`field_summary` and `_field_summary_metrics` — per-field droplet-size and phase-intensity statistics with
the honest-name result dict and its measured caveats — moved **verbatim** into
`toolbox/invitro/field_summary.py`. `invitro_tools.py` re-exports `field_summary` for every caller (invitro
UIs, batch steps, timeseries); no number changed, pinned by `test_field_summary_is_byte_identical` and the
enrichment/halo tests. `invitro_tools.py` dropped **799 → 605** lines; the per-file ceiling ratcheted to 605.
Remaining sections (coarsening, C_sat, contact-angle, fusion, sedimentation) follow.

## [1.6.214] - 2026-07-21
### Changed — **invitro decomposition step 2: the partition-coefficient domain moves to its own module (byte-identical).**
The calibration-sensitive quantitative core — `partition_coefficient_local` and its `_pc_*` phase helpers,
the assumptions-scoped `partition_measurement`, the no-cell-mask `partition_coefficient_field`, and the
dilution-series `estimate_phase_boundary` — moved **verbatim** into `toolbox/invitro/partition.py`.

- **`invitro_tools.py` re-exports** the four public entry points, so every caller (invitro fluor/BF UIs,
  `batch/steps/invitro_steps.py`, `timeseries_invitro_tools.py`) imports them unchanged. Move, not rewrite
  — no background handling, fit, or reported K_p changed.
- **Byte-identical:** `test_partition`, `test_partition_local_characterization`,
  `test_partition_measurement_characterization`, and the calibration/ΔG net pass. One test's monkeypatch
  target was updated to follow the moved `napari_show_warning` binding (assertions and values unchanged —
  the OVER-INCLUSIVE mask warning still fires). Moved keys recorded in the drop-guard's `_DELIBERATE`.
- `invitro_tools.py` dropped **1623 → 799** lines; the per-file ceiling ratcheted to 799. Remaining domains
  (field_summary, coarsening/C_sat, contact-angle, fusion, sedimentation) follow in later commits.

## [1.6.213] - 2026-07-21
### Changed — **invitro decomposition step 1: the size-distribution domain moves to its own module (byte-identical).**
Begins decomposing the 2,051-line `invitro_tools.py` by analysis domain. The MLE size-distribution path —
`fit_size_distribution_mle`, `fit_size_distribution`, and the phase helpers (`_fit_size_models`,
`_powerlaw_tail_comparison`, `_size_distinguishability`, `_size_verdict`) — moved **verbatim** into the new
`toolbox/invitro/size_distribution.py`.

- **`invitro_tools.py` re-exports** the two public entry points, so every caller (the invitro fluor/BF UIs,
  `batch/steps/invitro_steps.py`, the op-catalog api string) imports them unchanged. Move, not rewrite — no
  fit, threshold, or reported number changed.
- **Byte-identical:** `test_size_distribution_mle_characterization` and the invitro size tests pass
  unmodified; the moved keys are recorded in the drop-guard's `_DELIBERATE` set.
- `invitro_tools.py` dropped **2051 → 1623** lines; a new per-file ceiling was established there (1623) that
  ratchets down as the remaining domains (partition, field_summary, spatial, analysis) move out in
  follow-on commits.

## [1.6.212] - 2026-07-21
### Added — **A structural guard that every `_mpx` pixel-size accessor uses the ONE canonical helper (redundancy_consolidation axis 1).**
Pixel size scales every physical-unit measurement (viscosity, ΔG, size, density); a per-UI `_mpx()` that
re-derives it inconsistently silently corrupts units in one workflow but not another. Axis 1 of the
redundancy-consolidation spec routed every `_mpx` through the canonical `pixel_size_um_or_default`; this
adds the missing structural ratchet so it stays that way.

- New **`tests/test_pixel_size_single_accessor.py`** (core): an AST guard asserting every function named
  `_mpx` in the package references `pixel_size_um_or_default`, with a canary (a bespoke
  `dr.get('microns_per_pixel_sq') or 1.0` accessor is flagged; a routed one passes). Confirms all 10 current
  `_mpx` accessors route through the one helper. `test_pixel_size.py` pins the accessor's behaviour; this
  pins the structure — a new UI cannot quietly re-open the silent-units hole.
- No behaviour change. redundancy_consolidation axes 2–4 (background mechanics, worker lifecycle,
  stack-access) remain open.

## [1.6.211] - 2026-07-21
### Changed — **scientific_exceptions DONE: all 15 result-path handlers classified; none fabricate a default.**
Completing the scientific-exception guard: the remaining 11 broad handlers the AST guard flagged — in
`condensate_physics_tools` (5), `invitro_tools` (3), `vpt_tools` (3) — were classified and annotated
`# broad-ok:` with body-matched reasons.

- **The finding:** none of the five fit/measure modules fabricates a plausible default on failure. Every
  flagged handler reports the failure honestly — an all-NaN fit result with a `fit_success=False` flag, an
  explicit verdict string ("Power-law fit failed; confinement not assessed"), a fall-back to an
  already-measured value (the equivalent radius when the ellipse fit fails; the retained power law when the
  confined model fails), or an optional-backend/optional-check probe (CuPy version, intensity-semantics
  availability). So each was annotated, not converted — the correct action once classified by return value.
- The result-swallow ratchet is now **0** and the `toolbox` exception ratchet dropped **514 → 498** across
  the two increments. The guard catches any NEW broad handler that returns a fabricated scientific default.
- No scientific output changed; only failure-path documentation. Spec STATUS → DONE.

## [1.6.210] - 2026-07-21
### Added — **A guard against scientific modules silently returning a wrong NUMBER (scientific_exceptions Part 2).**
The exception-budget ratchet counts broad handlers; it does not ask what they *return*. A broad `except`
around a fit or calibration that then returns a plausible default is a silent wrong-number generator — the
audit's #4 concern. This adds the rule as a test and begins the classification.

- New **`tests/test_no_scientific_result_swallowing.py`** (core): an AST guard over the five fit/measure
  modules (vpt_tools, condensate_physics_tools, frap_tools, invitro_tools, partition_enrichment_tools) that
  flags any un-annotated broad `except` whose body directly `return`s a non-`None` value. Ratchet-style
  (pinned at today's count, only ever down) with a canary proving it flags a fabricated-default handler and
  passes a re-raising / None-returning / `# broad-ok:`-annotated one.
- **Measured inventory: 15 result-swallowing handlers** (far fewer than the 51 raw broad handlers — most
  re-raise or log). **frap_tools classified (4):** three return an all-NaN fit result AND warn the user (an
  honest missing value, not a fabricated default) and one is a documented degraded baseline fallback, so
  they are annotated `# broad-ok:` rather than converted. The scientific-swallow ratchet drops 15→11 and the
  `toolbox` exception ratchet 514→509.
- **Remaining (follow-on):** the 11 handlers in vpt_tools / condensate_physics_tools / invitro_tools —
  annotate the honest NaN-returners, convert genuine fabricated-default swallowers to typed raises. One
  module per commit. No scientific output changed; only failure-path documentation.

## [1.6.209] - 2026-07-20
### Changed — **Complexity ratchet 121 → 120: `field_summary` non-empty metrics extracted (byte-identical).**
The 182-line in-vitro whole-field summary — dominated by an ~80-line docstring and two large inline
measured-caveat comments — had its non-empty compute and result dict extracted into a helper, leaving the
orchestrator with the docstring, setup, and the empty branch. No number moved.

- **`_field_summary_metrics(props, image, bg_mask, cond_mask, microns_per_pixel, field_area_um2)`** carries
  the droplet-size and phase-intensity metrics and the honest-name result dict — with its deprecated
  aliases kept for back-compat and the measured caveats on what each quantity is and is not (the area
  fraction is a 2-D projection, not a volume fraction; the intensity ratio is not a partition coefficient;
  the dense/dilute contrast is pedestal-exact but not halo-immune). The `n == 0` empty branch (a different
  key set — no `intensity_ratio` / `dense_dilute_contrast`) stays in the orchestrator.
- **Pinned byte-identical** by a new `test_field_summary_is_byte_identical` — the exact populated dict and
  the empty branch — on a deterministic four-droplet scene (pure numpy/skimage, portable). The existing
  halo/contrast property tests pass unmodified.
- `_MAX_LONG_FUNCTIONS` lowered 121 → 120 (the ratchet only moves down); recorded in the drop-guard's
  `_DELIBERATE` set.

## [1.6.208] - 2026-07-20
### Changed — **Complexity ratchet 122 → 121: `qc_focus` split into its result-branch phases (byte-identical).**
The 203-line focus/sharpness QC check — a big dispatch of result dicts with dense measured rationale — was
split into pure helpers, leaving the orchestrator with just the na/info branches. No number moved.

- **`_qc_focus_stack(a)`** — the 3D per-frame band-pass-energy branch (flags frames far below the median).
- **`_qc_focus_absolute(width, limit)`** — the single-image diffraction-limit verdict: the
  refuse-when-nothing-sharp path (a blurry cell can't hide a sharp punctum, but with no small object there
  is no evidence of focus) plus the deliberately wide gross-defocus screen (the step-vs-blob conversion
  constant makes an absolute ratio uncertain by ~1.5×; the comparative use across a dataset cancels it).
- **Pinned byte-identical** by a new `test_qc_focus_is_byte_identical` that exercises **all five** result
  branches (stack→warn, absolute→good, refuse→na, info, flat→na) on pure numpy/scipy inputs (portable) and
  asserts the exact status + value + diag scalars. The existing focus property tests pass unmodified.
- `_MAX_LONG_FUNCTIONS` lowered 122 → 121 (the ratchet only moves down); recorded in the drop-guard's
  `_DELIBERATE` set.

## [1.6.207] - 2026-07-20
### Changed — **Complexity ratchet 123 → 122: `topology_metrics` basin-count phase extracted (byte-identical).**
The 192-line per-cell structural-envelope metric had its comment-dense basin-count phase — topological-
persistence peak counting with a range-vs-noise flat-field guard — extracted into a pure helper, leaving a
~55-line orchestrator (basic stats + connectivity). No number moved.

- **`_topo_basin_metrics(envelope, mask, image_noise)`** returns the basin-related keys (the caller
  `update`s them onto its dict), carrying the full measured rationale for why basins are gated by
  topological persistence rather than a bare `min_distance` or a global prominence gate. The dead
  `min_basin_distance` / `ball_radius` default computation (unused by the persistence method) was dropped in
  the move; the parameters stay in the signature.
- **Pinned byte-identical** by a new `test_topology_metrics_is_byte_identical` that feeds a **synthetic
  numpy envelope directly** (bypassing the GPU-routed rolling-ball), so the pure numpy/scipy metric is
  isolated and the golden values are platform-portable — exact basin count / persistence gate + list / cov
  / roughness / components / largest-frac on a peaked field (structure branch) and a near-flat field (flat
  branch, which omits `topo_noise_known`). The existing basin-count property tests pass unmodified.
- `_MAX_LONG_FUNCTIONS` lowered 123 → 122 (the ratchet only moves down); recorded in the drop-guard's
  `_DELIBERATE` set.

## [1.6.206] - 2026-07-20
### Changed — **Complexity ratchet 124 → 123: `count_molecules_single` split by computational phase (byte-identical).**
The 214-line single-trace N&B molecule counter was decomposed into pure per-phase helpers, leaving a
~55-line orchestrator. No number moved.

- **Phases extracted:** `_estimate_pedestal_read_noise` (the two camera constants — pedestal and
  read-noise floor — read from the trace's own post-bleach tail, the dark reference) and
  `_fit_counting_nu` (the ν = variance-vs-mean slope fit: a free intercept when the trace has a noise
  floor, else through the origin, with the read-noise-corrected fallback). Each dense measured-rationale
  block moved with its phase.
- **Pinned byte-identical** by a new `test_count_molecules_single_is_byte_identical` — exact
  ν / N / bleach_r² / pedestal / read_noise_var / accepted / n_points on a clean trace (the through-origin
  branch) and a read-noise+pedestal trace (the free-intercept branch), so both ν-fit paths are covered.
  The existing accuracy and pedestal-removal property tests pass unmodified.
- `_MAX_LONG_FUNCTIONS` lowered 124 → 123 (the ratchet only moves down); recorded in the drop-guard's
  `_DELIBERATE` set.

## [1.6.205] - 2026-07-20
### Changed — **Complexity ratchet 125 → 124: `fit_coarsening` split by computational phase (byte-identical).**
The 227-line coarsening-mechanism classifier (Ostwald ripening vs coalescence vs arrested) — the fifth and
last long physics-fit function in `condensate_physics_tools` — was decomposed into pure per-phase helpers,
leaving a ~35-line orchestrator. No number moved.

- **Phases extracted:** `_coarsening_powerlaw_fits` (the two `curve_fit`s + R²), `_coarsening_is_arrested`
  (the slope-test that decides whether the radius grew *at all* — a physical claim, never a fit statistic)
  and `_coarsening_confidence` (the seeded residual bootstrap + confidence tiers). The single
  `napari_show_warning` became a returned flag the orchestrator emits, keeping the helpers pure. Two
  provably-dead locals (`noise`, `r2_gap`) were dropped in the move.
- **Pinned byte-identical** by a new `test_fit_coarsening_output_is_byte_identical` — exact
  `preferred_mechanism` / confidence / R²s / rate constants / bootstrap agreement / radius change on
  Ostwald and arrested scenarios (the bootstrap is seeded via `default_rng(0)`, so its agreement is
  deterministic). The existing arrest-classification property tests in `test_coarsening_arrest.py` pass
  unmodified.
- `_MAX_LONG_FUNCTIONS` lowered 125 → 124 (the ratchet only moves down); the decomposition is recorded in
  the drop-guard's `_DELIBERATE` set.

## [1.6.204] - 2026-07-20
### Changed — **Complexity ratchet 126 → 125: `link_trajectories_bayesian` split by computational phase (byte-identical).**
The 245-line Bayesian/Hungarian trajectory linker — which feeds every VPT viscosity PyCAT reports — was
decomposed into pure per-phase helpers, leaving a ~50-line orchestrator. Lifecycle/complexity only; no
number moved.

- **Phases extracted:** `_bayesian_cost_defaults` (resolve the None-defaulted cost params),
  `_start_new_tracks` (open tracks when none are viable), `_build_frame_cost_matrix` (the per-frame
  viable×detection cost block + death/birth/dummy structure) and `_apply_frame_assignment` (the Hungarian
  solve written back onto the DataFrame + active-track state). Two provably-dead locals (`_sigma2`, the
  unused `assigned_*` sets) were dropped in the move.
- **Pinned byte-identical** by a new `test_bayesian_linker_assignment_is_byte_identical` — it asserts the
  exact per-detection `track_id` + `link_cost` on a fixed scenario exercising births, ongoing links, a
  bridged dropout gap, velocity prediction and the area-consistency cost. The Hungarian solve is sensitive
  to the exact cost matrix, so identical output proves the construction was preserved; the existing
  purity/gap-closing/ambiguity property tests in `test_linkers.py` pass unmodified.
- `_MAX_LONG_FUNCTIONS` lowered 126 → 125 (the ratchet only moves down). The linker's helpers are
  allow-listed in the input-mutation guard (they write into the linker's OWN `active`/`df`, not a caller's
  array) and the decomposition is recorded in the drop-guard's `_DELIBERATE` set.

## [1.6.203] - 2026-07-20
### Changed — **One figure module: the deprecated `figure_publication.FigureSpec` shim is removed (figurespec_merge cleanup).**
The 1.6.192 merge made `figure_spec.FigureSpec` canonical but left `figure_publication.py` in place as the
home of the validated rendering primitives plus a deprecated `FigureSpec` shim that `figure_spec.refine()`
still constructed internally. This finishes the consolidation: one figure module, no deprecated duplicate.

- **Primitives folded into `figure_spec.py`** — `apply_spec`, `add_significance_bracket`, `export_figure`,
  `PUBLICATION_PALETTE`, `JOURNAL_COLUMN_MM`, `THEMES`, `_recolor_series` — now reading the canonical spec's
  field names directly (`x_label`/`y_limits`/`font_size_pt`/`journal_column`/`significance_brackets`).
- **`refine()` applies the canonical spec directly** via `apply_spec(fig, spec)` — no field-name mapping
  through a deprecated `FigureSpec`. Output is byte-equivalent (the merge changed the API surface, not
  pixels).
- **`figure_publication.py` deleted.** Its one external consumer — `plot_backend_pyqtgraph`'s
  `PUBLICATION_PALETTE` import — is repointed to `figure_spec`; the comparative-figures UI never referenced
  it.
- **Tests migrated:** `test_figure_publication.py` → `test_figure_spec_primitives.py` (repointed to the
  canonical spec; the deprecated class's redundant JSON-serialization tests dropped — the canonical
  round-trip is already covered in `test_figure_spec.py`); `test_figurespec_merge.py`'s two shim-dependent
  tests replaced with direct assertions on `refine()`'s output. Full `pytest -m core` green. Unblocks the
  not-yet-written `publication_features` and `explore_refine_export`.

## [1.6.202] - 2026-07-20
### Fixed — **Brushable plots tear down on close — figures, callbacks and subscriptions stop accumulating (plot_lifecycle Parts A/B).**
The audit found ">20 matplotlib figures" open during a session: `make_pickable` (the one integration every
brushable plot uses) connected two canvas callbacks, stored an overlay artist and a `LazyRefs` sequence on
the figure, and had NO teardown — and two sibling brushing helpers subscribed a CLOSURE the service holds
strongly (which Part C's weak-method net does not catch). Every plot window left all of that behind. Part C
(the `SelectionService` self-defense) shipped in 1.6.187; this is the UI half.

- **`make_pickable` now tracks its connection ids** and gets `dispose_pickable(figure)` — disconnect the
  pick/key/close callbacks, remove the selection overlay, drop the ref sequence, and `plt.close` the figure.
  Idempotent (a close signal can fire twice). Teardown is also wired to the figure's own `close_event`, so a
  closed window cleans up even without an explicit dispose. Selection *behaviour* is unchanged — lifecycle
  only.
- **`cohort_targets.attach_histogram_brushing` and `comparative_figures._attach_object_brushing` now return
  a `dispose`** that unsubscribes the (strongly-held) closure and disconnects its canvas cid — closing the
  subscription leak the weak-method net cannot reach.
- **Feature Explorer no longer leaks per column switch.** Its mini-histogram reuses one figure, and
  `fig.clear()` does not drop canvas callbacks — so every column switch used to leave another
  `button_press_event` cid and a stale subscription behind. It now disposes the previous brushing before
  re-wiring.
- **The suite's ">20 figures" warning is gone** — a `conftest` autouse fixture closes leftover pyplot
  figures after each test (test hygiene; runs after the body, so a test asserting on `plt.get_fignums()`
  mid-run is unaffected).
- `tests/test_plot_lifecycle_dispose.py` (core, headless via the Agg backend) pins it: a pick reaches the
  handler before dispose and not after; dispose closes the figure and returns `get_fignums()` to baseline;
  the overlay and refs are released; dispose is idempotent and safe on a never-pickable figure; the
  close_event wiring runs teardown; N open→dispose cycles do not grow the open-figure count; and the
  histogram-cohort `dispose` unsubscribes the closure across simulated column switches. Spec Parts A/B
  STATUS → DONE.

## [1.6.201] - 2026-07-20
### Added — **Sessions now persist the user's entered workflow parameters (session_persist_settings Part 2).**
A saved session recorded the layers, dataframes and calibration but NOT the workflow parameters the user
entered (thresholds, radii, method choices), so a reloaded session could not reproduce the analysis setup.
It now carries them — reusing the ONE parameter record that already exists rather than inventing a second
capture path.

- **The recorded batch config travels with the manifest.** `session_manifest.workflow_to_manifest_extra`
  serializes the batch processor's own config (its single source of parameter truth — the same dict
  `save_config` already writes) into a `workflow` manifest block; `write_session_outputs` attaches it on
  save. No recorded steps → no block, so an un-recorded session's manifest is byte-identical to before.
- **Restored into the processor on load.** `_read_session_payload` surfaces the `workflow` block into the
  payload (Qt-free, on the worker) and `_apply_session_payload` restores it into
  `central_manager._pycat_batch_processor.config` — so the reloaded session carries the exact recorded
  parameter set, available for replay and inspection in "Recorded Steps". Recording stays OFF (the restored
  steps are a completed recording, not a live one).
- **Backward-compatible both ways.** `workflow_from_manifest` returns None for a manifest written before
  this feature (or one that recorded nothing), so old sessions load unchanged.
- **Part 1 (manual pixel size) was already satisfied** — the manifest already stored and restored a
  user-entered pixel size with honest provenance. Added a regression guard test so a reload never silently
  drops the calibration the user typed.
- `tests/test_session_persist_workflow.py` (core) pins the serialize/deserialize pair, the full manifest
  round-trip, the loader payload surfacing + processor restore, the pre-feature back-compat (both read and
  apply leave old sessions untouched), and the pixel-size round-trip. Spec STATUS → DONE.

## [1.6.200] - 2026-07-20
### Fixed — **Clearing the workspace now resets every open method widget's fields (session_clear_reset Bug 2).**
Clearing reset the layers, the data repository and the status circles the clear path knew about, but left
each toolbox method widget's spin boxes and dropdowns showing the previous workflow's values — because each
UI builder created its own `FieldRegistry` (`ui/field_status.py`) as a local island the clear path could not
find. The fix is a central handle every registry registers with.

- **A Qt-free `FieldRegistryHub` (`utils/field_registry_hub.py`).** Holds the live field registries WEAKLY
  (a closed widget's registry drops out on its own — no leak, no resetting a dead widget) and resets them as
  one. One registry's `reset_all()` raising does not block clearing the rest. Being Qt-free, the clear
  plumbing and its tests never drag in PyQt5.
- **Coverage grows by construction, not by wiring.** `FieldRegistry.__init__` registers with the process-wide
  hub, so a new method widget's fields reset on clear without its builder remembering to opt in.
- **`_clear_everything` calls `active_field_registries().reset_all()`** — returning every open method widget
  to its "~fresh open" state. Which fields reset is `FieldRegistry.reset_all`'s existing decision: OPTIONAL
  and EXPERT fields return to defaults; a REQUIRED value the user supplied (no default) is left alone.
- `tests/test_field_registry_hub.py` (core) pins the mechanism — register/reset/return-count, weak-ref prune,
  idempotent registration, exception isolation, the singleton, `_clear_everything` calling the reset, and the
  auto-registration wiring (Qt, importorskip). The spec's Bug 2 STATUS is updated to DONE.

## [1.6.199] - 2026-07-20
### Fixed — **Session loader: the freeze fix is confirmed complete; the `ui_modules` session-map re-export is completed.**
Following up the session-loader spec's last open item (Bug 2, "the freeze"): the staged off-thread load is
**already implemented and tested**, so this closes the spec and fixes a related latent fragility surfaced
while verifying it.

- **The freeze fix (Part C) is done and Qt-free-tested.** `load_session` is split into
  `_read_session_payload` (the slow decode — `tifffile.imread` per layer, `pd.read_csv` per table — which
  **takes no viewer, so it structurally cannot create a layer**) and `_apply_session_payload` (the only
  half that calls `viewer.add_*`, on the caller thread). `load_session(use_worker=True)` runs the read on a
  worker via the tested `qt_worker.run_with_progress`; both "Load Session" handlers pass it. The read/apply
  split rides the tested main-thread-marshalling contract rather than unverified threading, and
  `tests/test_session_load_threading.py` pins it (reader takes no viewer / decodes into a payload / applier
  is the only half that adds layers / `load_session` round-trips unchanged). The spec STATUS is updated to
  COMPLETE.
- **Completed the `ui_modules` re-export.** `_SESSION_METHOD_SWITCH` / `_SESSION_METHOD_BY_DATA` /
  `_FileDropFilter` moved to `menu_manager` in the 1.6.149 decomposition, and the re-export comment promised
  they stayed importable from `ui_modules` — but the import line brought only `MenuManager`, leaving
  `ui_modules._SESSION_METHOD_SWITCH` resolvable **only by full-suite import order** (so
  `test_the_method_REGISTRY_wires_VPT_correctly` passed in the suite but failed in isolation). The re-export
  now includes all three, honoring the documented promise and making the test order-independent; the
  complexity ratchet is respected (`ui_modules.py` stays at its ceiling).

## [1.6.198] - 2026-07-20
### Added — **Resolution routes through the entity registry: views ask the one authority where an object is now.**
Completes the entity-registry spec. The registry authority (1.6.189) was populated at the identity
chokepoint (1.6.197); now the **consumer side** consults it. A view carries only the entity id and resolves
its *current* location through the registry, instead of trusting the bbox/frame/layer baked into the ref at
wiring time — which can go stale after a re-crop, a layer re-add, or a frame reindex. This closes the "a
row can carry correct identity with **stale** location" divergence end to end.

- New **`object_ref.location_from_registry(ref)`** — refreshes a ref's `EntityLocation` (bbox / frame /
  layer id / source) from `default_registry()` when the entity is known there. Both **`resolve_in_viewer`**
  (interactive navigation) and **`resolve_offline`** (batch crop) call it first, so an `update_location`
  propagates to every subsequent resolution — no per-view stale cache to chase. Per field, a registry
  `None` leaves the ref's own value in place; the registry may even supply a bbox the ref lacked.
- **Honest fallbacks** (the "a wrong location is worse than an admitted missing one" contract): a ref with
  no `entity_id`, or an entity the registry does not know (dataset closed / never registered), is returned
  unchanged — the ref's last-known location is used, never an invented one.
- New **`tests/test_selection_registry_routing.py`** (`core`): a stale ref is refreshed from the registry;
  an `update_location` propagates to resolution; an unknown/no-id ref is untouched; a registry `None` field
  leaves the ref's own value; and **`resolve_offline` crops the registry's current location, not the stale
  bbox** baked into the ref (the divergence test at the consumer side). The existing brushing and
  selection-service suites pass unchanged.

## [1.6.197] - 2026-07-20
### Added — **Entity registry populated at the identity chokepoint: id → current location, from one record.**
The registry authority shipped in 1.6.189 but nothing populated it (it was dependency-gated on the
auto-identity-stamping chokepoint, now landed in 1.6.196). This closes the "a row can carry correct identity
with **stale** location" divergence *at the source*: identity and location are now registered together from
one record at the same finalization point that stamps the id.

- New shared **`default_registry()`** in `utils/entity_registry.py` — the process-wide authority a view
  resolves location through, instead of caching bbox/layer/frame off whatever table it was handed.
- New **`entity_ref.populate_registry(table)`** — one stamped row → one `EntityRecord` binding the id to its
  current `EntityLocation` (bbox / layer id / frame / source), provenance, and dataset. It runs
  automatically inside `finalize_entity_table`, so **every chokepoint-stamped table also registers its
  rows** — identity and location co-generated (per-row frames included), never crossed. Additive and
  guarded: it never costs the caller their table, and an unstamped table registers nothing.
- New tests in **`tests/test_auto_identity_stamping.py`** (`core`): the chokepoint populates the registry
  with matching id + location; per-row frames resolve to records with their own frame (no divergence); an
  unstamped table registers nothing; and the shared default registry resolves a chokepoint-stamped row.
- **Remaining** (noted in the spec): routing `SelectionService` navigation through `resolve(id)` so views
  consult the registry as the location authority — a consumer-side change on the brushing/selection path.

## [1.6.196] - 2026-07-20
### Added — **Automatic entity-identity stamping via the result-finalization chokepoint.**
Manual `stamp_entity_ids` reached only **3 of many** object-producing paths; every new analysis was one
forgotten call away from silent row-position linking, and `operation_id` was a hard-coded string (an
interactive/batch divergence risk). Identity is now applied by **declaration** at a chokepoint, so coverage
grows by declaring a spec — not by scattering more stamping calls.

- New in **`utils/entity_ref.py`**: a frozen **`EntitySpec`** (entity_type + label/parent/frame columns), a
  declaration registry (`register_entity_spec` / `entity_spec_for`), and **`finalize_entity_table(table,
  operation_id)`** — the chokepoint. If the operation declares a spec, it stamps identity **and** location
  in one pass with the operation's real id; an undeclared operation is returned untouched (honestly
  row-position-linked); it is **idempotent**, so the automatic path and a manual call never double-stamp.
- **`operation_runner.execute` calls it automatically** on a DataFrame result, driven by the operation
  captured from `operation_context` (1.6.155) — so `operation_id` comes from the declaration/context, **not
  a hard-coded string**. A non-DataFrame result or an undeclared operation passes through unchanged; a
  finalization failure never costs the caller their result.
- **The 3 manual sites migrated** (cell / puncta / region props) to the declaration with **byte-identical
  ids** (proven by a parametrized equivalence test), and the top previously-unstamped producers
  (**condensate, tracks, colocalized objects**) now gain identity by declaration. Tracks declare
  `frame_column='frame'`, so a multi-frame table stamps each row with its **own** frame (the per-row-frame
  fix, 1.6.188, now flowing through the chokepoint).
- The entity-id **scheme is unchanged** (it is validated) — only *where and how reliably* it is applied.
- New **`tests/test_auto_identity_stamping.py`** (`core`): a declared operation is stamped automatically;
  `operation_id` flows from the declaration (same rows under two ops → different ids); per-row frames;
  identity + location co-generated; undeclared → untouched; the migrated sites are byte-identical; a
  previously-unstamped producer gains identity by declaring; idempotency; and the runner stamps at
  finalization (and leaves non-DataFrame/undeclared results alone).

## [1.6.195] - 2026-07-20
### Fixed — **headless-CI regressions: `core` tests must not pull in Qt.**
Six `core` tests passed locally (where PyQt5/qtpy are installed) but failed on the headless `core` CI
runner, violating the tier's contract ("no napari, no Qt — must pass headlessly"). Both were import-source
issues, fixed without any behaviour change:

- **`detect_beads_stack` (and its serial-path helpers) imported `iter_frames`/`materialize_stack` from the
  Qt-laden `file_io`**, which imports PyQt5 at module scope — so the new `test_detect_beads_stack_
  characterization` (added as the split's safety net) errored with `ModuleNotFoundError: PyQt5` in CI.
  These streaming helpers are actually *defined* in the headless `stack_access` (`file_io` only re-exports
  them), so `vpt_tools` now imports them from `stack_access` directly. `detect_beads_stack` no longer
  imports `file_io` at all (verified), and the detection table is byte-identical (40 detections, same
  order/coordinates).
- **`clear_all_without_saving(confirm=False)` imported `qtpy` unconditionally**, so a headless caller (and
  the `confirm=False` test) crashed without Qt. The import now lives inside the `if confirm:` block — only
  the path that shows a dialog needs Qt. The two tests that exercise the confirm *dialog* itself now
  `pytest.importorskip('qtpy')`, so they skip headlessly (as the suite's other Qt tests already do) rather
  than fail.

Full `pytest -m core` green locally (1395 passed); the headless CI path is now Qt-free for these paths.

## [1.6.194] - 2026-07-20
### Added — **Feature families: an organizing schema over the measurement layer.**
Measurements were emitted as a **flat** list of columns — `area`, `intensity_mean`, `viscosity`,
`ripley_l_max` all in one undifferentiated row, with no indication that the first is cheap geometry and the
last a material-state measurement requiring a fit. This adds the grouping the Feature Explorer, redundancy
analysis, and any "export only the geometry columns" action all want. **Purely additive — no emitted table
or existing ontology consumer changes.**
- `measurement_ontology.py`: new `FeatureFamily` str-enum (Geometry, Intensity, Partition, Material-state,
  Spatial, Colocalization, Topology, QC — a small, stable, canonical-order set), and `MeasurementDef` gains
  a `family` field **defaulting to `None`** (so nothing that reads the ontology breaks). The 22 populated
  ontology entries are assigned their family.
- New `utils/feature_families.py`: `classify_column(name)` resolves a column's family **ontology-first**
  (authoritative), then a **curated substring fallback** (a labelled guess), else `None` — and returns the
  `source` (`'ontology'` | `'inferred'` | `None`) so a guessed grouping is **never mistaken for a defined
  one**. `family_for_column` is the family-only accessor; `group_columns_by_family(columns)` partitions a
  table's columns into families in canonical order with an Ungrouped (`None`) bucket **last** — and drops
  nothing (the union of buckets equals the input). A genuinely ambiguous column stays Ungrouped, because a
  wrong family is more misleading than an absent one.
- Tests (`core`): `tests/test_feature_families.py` — additive default, every ontology family returned and
  marked `'ontology'`, substring fallback marked `'inferred'`, ambiguous → `None`/Ungrouped, canonical
  order preserved, nothing dropped, and str-enum JSON serialization. This is a *view* over existing
  columns; it does not reorganize the ontology module or the emitted tables.

## [1.6.193] - 2026-07-20
### Fixed — **Loading a session now REPLACES the workspace instead of stacking onto it.**
Loading a saved session used to load its layers/tables *on top of* whatever was already open
(`session_loader` called `open_image_auto(clear_first=False)`), so the previous dataset's layers, tables,
and identity references lingered underneath the restored one — two sessions coexisting in one workspace. A
session is a DOCUMENT, not an overlay: loading it now clears first.
- Both "Load Session" handlers in `ui/menu_manager.py` (the single-session picker `_load_discovered_session`
  and the multi-stem folder loader `_on_load`) now call `clear_all_without_saving(...)` **before**
  `load_session(...)`.
- The clear is **guarded, never silent**: if the workspace has layers it prompts (the same discard warning
  the Clear button uses), and `clear_all_without_saving` now **returns `True`/`False`** (cleared / user
  cancelled) so the handler ABORTS the load when the user declines — their current, possibly-unsaved work is
  never discarded without a yes. On an empty workspace there is nothing to clear and nothing to confirm.
- Tests (`core`): `tests/test_session_clear_load.py` — the return-contract (cancel → `False`, no clear;
  confirm → `True`, clears; `confirm=False` → clears unprompted) and an AST guard that **every**
  `load_session` caller clears first (Qt-bound handlers, verified structurally).
- Not addressed here (honest follow-on): the method-widget-reset-on-clear polish and the
  `session_persist_settings` spec (whose premise is stale — pixel size already persists via the session
  manifest); documented in their spec status blocks rather than implemented redundantly.

## [1.6.192] - 2026-07-20
### Changed — **The two FigureSpec systems merged behind one canonical spec; significance now rendered.**
`utils/figure_spec.py::FigureSpec` and `utils/figure_publication.py::FigureSpec` overlapped but differed —
a feature added to one was absent from the other, and no module knew which to use. Merged behind
`figure_spec.FigureSpec` (the canonical spec):
- The canonical spec **absorbs** the publication fields (journal `column`, `height_mm`, `theme`, `recolor`,
  `tick_format`, `significance_brackets`, `title_size_pt`) — **each defaulting off/None, so a spec that
  sets none of them renders EXACTLY as before**: the merge is pixel-equivalent for existing usage by
  construction.
- **`figure_spec.render()` now HONOURS significance** (the verified gap — the working bracket
  implementation lived only in `figure_publication`); a spec requesting brackets gets them, still driven by
  caller-supplied replicate-level pairs, never a pixel-level inference.
- New `figure_spec.refine(fig, spec)` applies theme / journal sizing / ticks / recolour / brackets to an
  already-rendered figure by **reusing the validated `figure_publication.apply_spec`** — so the output is
  byte-for-byte what the publication path always produced (the merge changes the API surface, not the
  pixels). Unit is inches internally; mm is converted at the journal-width boundary.
- `figure_publication.FigureSpec` is now **deprecated** (marked in its docstring) but fully functional —
  existing consumers keep working until they migrate.
- Tests (`core`): `tests/test_figurespec_merge.py` — the canonical spec carries every capability, default
  render is unchanged, render honours brackets, `refine` matches `apply_spec`, the JSON round-trip carries
  the new fields, and the deprecated shim still works; the existing `test_figure_spec` / `test_figure_
  publication` pass unmodified. Migrating the two consumers off the deprecated shim (then removing it) is
  the follow-on; nothing breaks meanwhile.

## [1.6.191] - 2026-07-20
### Changed — **Identity integration: `dataset_id_for` now returns a durable UUID for a readable file.**
Wires the dataset-identity registry (1.6.190) into entity identity: `dataset_id_for(path)` resolves a
**readable** file to its persistent UUID (surviving a move/remount/cross-platform, via the fingerprint
registry) instead of embedding the fragile PATH in every entity id. An absent or unreadable path — a test
fixture, a batch replay of a relocated file, a registry outage — falls back to the path string, so it is
**backward-compatible**: nothing that stamped a non-existent path changes, and a durable id never costs
the caller their table (any failure degrades to the path).
- `dataset_identity.default_registry()` is the process-wide registry (persisted to `~/.pycat/
  dataset_registry.json`, so a dataset keeps its UUID across sessions); `uuid_for_path()` is the
  readable-file → UUID entry point `dataset_id_for` routes through, cheap on a repeat (a known path returns
  its UUID without re-fingerprinting).
- Tests (`core`): `tests/test_dataset_identity.py` — a readable file's dataset id is a durable UUID and is
  stable; an absent path keeps the path-as-id (back-compat). Full core green.
- Follow-on: migrating OLD path-based session ids to UUIDs on load (rewrites ids in saved dataframes), and
  populating the entity registry / routing SelectionService navigation at the stamping chokepoint — the
  deeper wiring these mechanisms now enable.

## [1.6.190] - 2026-07-20
### Added — **Persistent dataset identity: a durable UUID + cheap fingerprint (path becomes a location).**
Dataset identity was the file PATH, which breaks on move / remount / cross-platform / copy / temp-cache —
and can resolve to the WRONG dataset if two files share a path. New **`utils/dataset_identity.py`**:
- `DatasetIdentity` (uuid + original_path + fingerprint) and `DatasetFingerprint` (size, mtime, ome_uuid,
  partial_hash); `DatasetRegistry.mint_or_recognise(path)` — exact-path hit reuses the UUID; a path miss
  that **fingerprint-matches** an existing dataset reuses THAT UUID with the updated path (identity
  survives a move); no match → a fresh UUID. Persisted to a small JSON sidecar so the same file gets the
  same UUID across sessions.
- **Cheap and honest:** `bounded_partial_hash` samples head + interior + tail (reads kilobytes, never the
  whole multi-GB file — measured in a test); the OME UUID is authoritative when present; a borderline
  match (same size, different bytes) becomes a **NEW dataset, never a merge** (merging two datasets'
  identities is far worse than a fresh UUID).
- Tests (`core`): `tests/test_dataset_identity.py` — same file → same UUID, moved file recognised keeps its
  UUID, borderline is new, OME UUID authoritative, the bounded-read measurement, and cross-instance
  persistence.
- The integration — routing `entity_ref.dataset_id_for` through the registry so `dataset_id` becomes the
  UUID (which changes what every entity id embeds and needs a one-time migration of old path-based session
  ids) — is the deliberate follow-on; this ships the mechanism.

## [1.6.189] - 2026-07-20
### Added — **Entity registry: resolve an entity id to its CURRENT location through one authority.**
A row carried an opaque `_pycat_entity_id` for equality AND separate location columns (bbox, layer id,
frame, source) generated independently — so it could carry correct identity with STALE location. New
**`utils/entity_registry.py`** closes that divergence:
- `EntityRecord` binds an entity's id, `EntityLocation` (bbox/layer/frame/source), provenance, and dataset
  in ONE record, so identity and location cannot be generated separately and drift apart.
- `EntityRegistry` — `register` / `resolve(id) → EntityRecord | None` / `update_location(id, …)` /
  `invalidate_dataset(uuid)`. A view holds only the id and resolves location through this authority, so a
  location change (a labels layer re-added, a re-crop, a frame reindex) is seen by every view at once, with
  no stale local cache to send it to the wrong place.
- **Resolution fails HONESTLY:** `resolve` returns `None` for an unknown id or a closed dataset — a view
  shows "cannot locate" rather than navigating to a guessed, wrong place (a wrong target is worse than an
  admitted miss).
- Tests (`core`): `tests/test_entity_registry.py` — register/resolve round-trip, unknown → None, the
  divergence test (`update_location` changes every subsequent resolve), `invalidate_dataset` → honest miss,
  identity+location in one record, re-register replaces.
- Additive: the location columns stay (tables remain standalone-readable) as a registry-backed cache.
  Wiring the registry's population into the identity-stamping finalization chokepoint and routing
  `SelectionService` navigation through `resolve` depends on the auto-identity-stamping mechanism + the
  dataset-UUID work; this ships the authority they will populate.

## [1.6.188] - 2026-07-20
### Fixed — **Entity identity: `stamp_entity_ids` now derives the frame PER ROW (auto-identity §C1).**
`stamp_entity_ids` took a scalar `frame` and stamped every row with it — correct for a single-frame table,
but for a multi-frame table (a tracked-object / time-series table where the same label recurs across
frames as DIFFERENT entities) it collapsed those distinct entities onto ONE id. Added `frame_column=`: when
present, identity derives its frame per row from that column; when absent, the scalar `frame` is used
exactly as before (back-compat). This is the concrete Part C1 fix from the auto-identity-stamping spec.
- Tests (`core`): `tests/test_entity_ref.py` — the same label in different frames now yields distinct ids
  (four (label, frame) pairs → four ids); a single-frame table with no frame_column is unchanged.
- The larger auto-identity mechanism — stamping automatically at the operation-runner finalization
  chokepoint driven by an `EntitySpec` on the `OperationSpec` (so coverage grows by declaration, not by
  new stamping calls) — is the architectural remainder tracked in the spec.

## [1.6.187] - 2026-07-20
### Added — **Plot/view lifecycle: SelectionService self-defense so subscriptions do not accumulate.**
A long session accumulated matplotlib figures and `SelectionService` subscriptions, and every selection
broadcast walks the subscriber list — an unbounded list is the lag source. The service already held
bound-method subscribers WEAKLY (a closed dock's method dies) and dropped dead handles on broadcast; this
adds the self-defense the leak finding demands, and makes it testable:
- `subscriber_count(*, include_deferred=True)` — the count of LIVE subscribers (dead weak handles pruned
  first), so a test can assert it returns to baseline across open/close cycles;
- `_prune_dead()` — a proactive sweep dropping subscribers whose weak handle has died (a view that closed
  without unsubscribing), so liveness does not have to wait for the next broadcast.
- Tests (`core`): `tests/test_selection_lifecycle.py` — the leak test (50 open→dispose cycles return to
  baseline), a closed view's bound method is pruned from the count, a broadcast never calls a
  garbage-collected subscriber, idempotent double-unsubscribe, and the deferred channel counted/pruned too.
- The per-view `dispose()` (disconnect canvas callbacks + close the figure) is the UI half; the
  weak-method design already covers the closed-dock subscription leak, and this is the service-level safety
  net that catches a missed one.

## [1.6.186] - 2026-07-20
### Added — **Analysis-aware kymographs — a line-scan over time paired with the measurements PyCAT computes.**
A roadmap capability with no implementation. New **`toolbox/kymograph_tools.py`**:
- `kymograph(stack, line, *, axis='time'|'depth', width_px, reduce, pixel_size_um, frame_interval_s)` — samples
  intensity along a line in every frame and stacks the profiles into a (position × time/depth) image. The
  stack is read with **`materialize_stack`, never `np.asarray`** — a lazy time-series `__array__` returns
  frame 0 only, and a kymograph is the worst place to hit that landmine (guarded by a test). Axes are
  labelled in µm / seconds only when calibrated, px / frame otherwise, and the `units` field says which;
  the averaging-band width is recorded.
- `colocalization_kymograph` — two channels' kymographs + the **per-time-slice Pearson** (the existing
  coloc metric); `object_property_kymograph` — a tracked object's property vs time from the per-object
  table.
- Tests (`core`): `tests/test_kymograph.py` — band velocity recovered from the slope, the lazy-stack
  guard, calibrated vs px/frame labels, per-slice Pearson matches an independent computation, a
  shrinking-diameter trend, and wide-band noise reduction without a slope shift. FRAP / phase-boundary
  variants and the draw-line UI are noted follow-ons.

## [1.6.185] - 2026-07-20
### Added — **Measurement ontology populated: Tier 2 geometry/intensity entries (transcribed, not invented).**
The ontology's machinery was well-built but Tier 2 (common `regionprops`-derived geometry/intensity) was
absent, so the Feature Explorer / figure labels / reliability captions showed `None` for the most-emitted
columns. Added six entries, each transcribed from scikit-image / the code with a real equation and units:
- `area` (`A = N_px × pixel_size²`), `equivalent_diameter` (`√(4A/π)`), `eccentricity`
  (`√(1 − b²/a²)`), `solidity` (`area/convex_area`), `intensity_mean`, `intensity_total`.
- **Units honesty:** the size entries carry `µm²`/`µm` with the caveat that the value is px²/px when no
  pixel size is set (calibration-dependent, not asserted); the intensity entries are `a.u.` with the
  offset/gain caveat. Every entry has a definition + equation + units (the well-formed guard), and every
  emitted key appears as a real column in the source (the no-orphan guard) — so the registry cannot fill
  with aspirational or blank claims. (Tier 1 scientific measurements were already present.)
- Transcribe-never-invent honoured: no reference is attached without the equation it supports.

## [1.6.184] - 2026-07-20
### Added — **SMLM / localization-table analysis — load, normalize to µm, feed the spatial stats that exist.**
The lab has super-resolution instruments and had no localization-table analysis — but the hard part (the
spatial statistics `ripleys_l` / `pair_correlation_function` / `nearest_neighbour_distance` /
`local_object_density`) already exists. This is the front door. New **`toolbox/smlm_tools.py`**:
- `load_localization_table(path, *, format='auto', pixel_size_um=None)` → a µm-normalized
  `LocalizationSet`. **Units are the whole risk:** detected from the column header (ThunderSTORM's
  `x [nm]` → µm) and REQUIRED explicitly (`pixel_size_um`) when the columns are bare — never guessed, the
  same gate images enforce. The same pattern in nm vs µm gives identical downstream stats (pinned).
- `temporal_merge(locset, *, radius_um, gap_frames)` — collapse a blinking molecule's repeated
  localizations to one, with `analyze_localizations` **warning that un-merged data OVER-COUNTS density**
  (never silently merged or not).
- `analyze_localizations(locset, *, cell_area_um2)` runs the existing Ripley/PCF/NN/density backend and
  reports the **median localization precision (nm) as the resolution floor** below which clustering is not
  real.
- Ontology entries added (`median_localization_precision_nm`, `ripley_l_max`, `nn_median`) with the
  precision-floor and blink-over-count caveats.
- Tests (`core`): `tests/test_smlm_tools.py` — nm→µm loading, the ambiguous-units gate, nm/µm identical
  stats, clustered-vs-random through the loader, temporal-merge reduces the over-count + warns, the
  precision floor, and the no-x/y-columns error. A points layer + a load UI are the thin follow-on.

## [1.6.183] - 2026-07-20
### Changed — **detect_beads_stack decomposed by pipeline stage (317 → 116 lines); VPT-baseline byte-identical.**
`detect_beads_stack` is the shared detection stage feeding the whole VPT viscosity chain (the ~8.325
baseline through TrackMate), and the most scientifically load-bearing function in VPT — so it was
GUARD-ANCHORED, not merely characterized. Split by pipeline stage into pure/worker helpers:
- `_choose_detection_backend` — the GPU / CPU-process-pool / serial tier selection (each costed for the
  stack; the equivalence guards pin that all three produce identical blobs);
- `_pool_predetect` — the process-pool coordinate pre-detection with its progress mapping;
- `_bead_hot_mask` — the hot-pixel-reject sensor mask;
- `_detect_all_frames` — the per-frame streaming loop, calling `_fast_frame_rows` (template + scoring) and
  `_precise_frame_rows` (Gaussian fit);
- `_assemble_detections` — the DataFrame build, `classify_beads`, and class filters.
- **Detection numerics, path outcomes, and output ORDER are untouched** (downstream linking is
  order-sensitive). The existing VPT equivalence guards (`test_vpt_gpu_equivalence`,
  `test_vpt_parallel_equivalence`, and the memo) pass unmodified, and a new serial-path characterization
  (`tests/test_detect_beads_stack_characterization.py`) pins the exact detection table — coordinates,
  order-sensitive hash, area, counts — on a seeded synthetic stack (the GPU-equivalence test skips without
  a GPU, so the serial path is now guarded on every machine). Revert is a clean single-function rollback
  if the baseline ever regresses. Complexity ratchet 127 → 126; truncation guard allowlisted.

## [1.6.182] - 2026-07-20
### Added — **Ratiometric / two-channel intensity-ratio analysis — the traps handled, not just the division.**
Multichannel confocals produce two-channel data and PyCAT can segment the objects, but there was no
ratiometric module — and a naive `A/B` is riddled with traps. New **`toolbox/ratiometric_tools.py`**:
- `ratio_image(N, D, *, background_num, background_den, threshold, mask)` → a `RatioResult` (the ratio
  image with `NaN` where the denominator is too small, the fraction thresholded, and the backgrounds /
  threshold used); `object_ratios(labels, N, D, …)` → a per-object table.
- **Background BEFORE ratio, always** — `(N−b_N)/(D−b_D)`; an un-subtracted offset bends the ratio toward
  1 (the pedestal test proves it). **Low-denominator pixels become `NaN`, not spikes**, and the excluded
  fraction is reported. **Both summary modes** — `ratio_of_means` (aggregate, robust) and `mean_of_ratio`
  (per-pixel, heterogeneity-sensitive) — are reported and labelled; neither is silently chosen. An optional
  **bleed-through coefficient** corrects `D − c·N` (no automatic unmixing); with none supplied the result
  is flagged uncorrected so the caller can warn.
- `ratio` registered in the **measurement ontology** with its equation and the background-first,
  mean-of-ratio, bleed-through, and thresholding caveats.
- Tests (`core`): `tests/test_ratiometric.py` — known-ratio recovery, the pedestal test, low-denominator →
  NaN with reported fraction, mean-of-ratio vs ratio-of-means (agree uniform / differ heterogeneous),
  bleed-through bias-toward-1 + coefficient correction, and the ontology entry. The tagged ratio LAYER and
  the channel-picker UI are the thin follow-on; the computation is delivered.

## [1.6.181] - 2026-07-20
### Changed — **partition_measurement: background-subtracted assessment extracted (191 → 110 lines), byte-identical.**
The Kp measurement-with-assumptions builder was dominated by its background-subtracted assessment — the
argument (and the branching) that the image cannot tell a camera pedestal from a genuine dilute phase (in
a partition measurement the dilute phase IS the denominator, so it is not a background to remove), so the
assumption is RESOLVED by a dark reference, stated by the caller, or recorded UNCHECKED rather than
guessed. That phase moved to `_partition_background_assumption(dark_reference, background_subtracted,
floor, dilute) → (checked, holds, detail)`, leaving a 110-line builder.
- **Behaviour-preserving, pinned as such.** `tests/test_partition_measurement_characterization.py` captured
  which branch fires and the exact `checked`/`holds`/`detail` of the `background_subtracted` assumption
  across all four inputs (dark reference / not-stated / stated-true / stated-false), plus the other
  assumptions and the measurement identity, before the split and asserts them unchanged after; the existing
  `test_claim_scoping` passes unmodified. Complexity ratchet 128 → 127; truncation guard allowlisted.

## [1.6.180] - 2026-07-20
### Changed — **fit_fusion_relaxation decomposed by phase (184 → 90 lines), proven byte-identical.**
The droplet-fusion relaxation fit fused the single-exponential-plus-drift curve fit, the tau confidence
interval, the observation-window adequacy check (a record shorter than ~3 τ biases τ low, which biases
η/γ by the same factor, and R² cannot see it), and a two-mode-relaxation test. Split into pure phase
helpers, each carrying its rationale:
- `_fusion_tau_ci` — the 95% CI on τ from the fit covariance;
- `_fusion_window_warn` — the relaxations-observed count (measured on the span, not the circular fitted τ)
  and the short-record warning;
- `_fusion_model_adequacy` — the residual runs test + the direct two-mode test (which catches 100% where
  the runs test catches ~62%, the drift term absorbing part of the slow mode).
- **Behaviour-preserving, pinned as such.** `tests/test_fusion_relaxation_characterization.py` captured the
  fitted parameters, R², the τ CI, the relaxations-observed count, the adequacy/two-mode verdicts, and
  WHICH warnings fire across adequate / short / two-mode / too-few-points traces before the split and
  asserts them unchanged after; the existing `test_fusion_physics` passes unmodified. Complexity ratchet
  129 → 128; truncation guard allowlisted.

## [1.6.179] - 2026-07-20
### Added — **FilterStore: the active analytical population, provably separate from selection.**
Two questions were tangled: *which entities am I examining?* (selection — transient attention) and *which
entities are in the active analysed population?* (filter). `SelectionState` answered the first; nothing
held the second as explicit state, so filtering happened inside individual analyses and a plot click could
surprise a user by changing results. New **`utils/filter_store.py`**:
- `Filter` (a NAMED restriction — `predicate` + `members` + `source` + `active`) and `FilterStore`
  (`set_filter` / `clear` / `population` / `is_active`) on its OWN change channel, reading and writing no
  selection state. `population()` is `None` when no filter is active — None means everything, never
  confused with an empty population.
- **The isolation invariant is the whole point, and it is enforced:** a filter change leaves
  `SelectionState` untouched and a selection change leaves the filter untouched (pinned both directions),
  plus a grep-level contract that no selection handler calls `set_filter` — a brush/click is attention,
  not a population change.
- `resolve_render_tier` — the four-tier emphasis resolver (excluded < filtered-in < selected < pinned)
  adapters call; `filtered_result_note` — a filtered result records its predicate + counts so it is never
  mistaken for an unfiltered one; `filter_table` — restrict to `population()`, never silently, input never
  mutated.
- Comparative-phenotyping "condition" is deliberately NOT merged into `FilterStore` here (noted for a
  future deliberate unification).
- Tests (`core`): `tests/test_filter_store.py` — population API, both isolation directions, the four tiers,
  the honest filtered-result note, `filter_table`, and no-implicit-filtering. The thin UI integration
  (per-adapter tier rendering + an explicit filter control) is the follow-on; the mechanism is delivered.

## [1.6.178] - 2026-07-20
### Added — **Feature Explorer: one legible card per measurement, aggregated from existing sources.**
The measurement platform (ontology, reliability/MRI, stability, redundancy, provenance) computed a lot but
had no single pane that made it legible. New **`utils/feature_explorer.py`** — `FeatureCard` +
`build_feature_card(table, key, *, context)`:
- pulls a measurement's definition / equation / units / caveats (ontology), reliability grade + worst-first
  reasons (MRI), stability verdict, correlated-with columns (the redundancy report), provenance summary,
  and a value distribution (mini-histogram);
- **aggregates, never recomputes** — every field is read from `context` (whatever ran) and degrades to
  `None` when its source is absent; only the distribution is derived, from the column itself. A card with
  just a definition, or just a distribution, is a correct (honest) card;
- mutates nothing.
- Thin dock (`ui/feature_explorer_dock.py`): a searchable column list + the card panel + a mini-histogram
  that reuses the 1.6.170 cohort emitter (`attach_histogram_brushing`), so a bin click selects those
  objects. All content comes from `build_feature_card`; the dock is a shell.
- Tests (`core`): `tests/test_feature_explorer.py` — a full card when all sources are present, a partial
  card with no ontology entry, per-source degradation with nothing fabricated, a distribution that matches
  an independent histogram, a correlated-with list that matches the redundancy report, the no-mutation
  contract, and an AST guard that the dock wires the assembler + the cohort histogram. (Live rendering
  needs an in-app glance.)

## [1.6.177] - 2026-07-20
### Added — **Feature redundancy analysis: find near-duplicate feature columns, report them, never drop them.**
PyCAT emits wide feature tables where several columns track the same underlying quantity (`area`,
`convex_area`, `equivalent_diameter` all track size), silently giving a downstream PCA/classifier four
copies of "size" four times the weight. Nothing reported it. New **`toolbox/feature_redundancy.py`**:
- `analyze_redundancy(table, *, method='spearman', threshold=0.95)` → a `RedundancyReport` (the |r| matrix,
  the redundant-column groups, one chosen representative per group with the reason, the droppable columns,
  and the excluded columns with reasons).
- **Spearman by default** — morphometric relationships are often monotonic-but-not-linear (area vs
  diameter is a square law) and Pearson understates that redundancy.
- **Transitive clustering, not pairwise dropping** — correlation-distance average-linkage clustering, so
  A~B, B~C groups all three even when A~C is just under threshold; an order-dependent pairwise drop would
  not.
- **The representative is chosen, not arbitrary** — an ontology-defined column beats a derived one, else
  the most complete (fewest NaNs), else alphabetical; the reason is recorded so the minimal set is
  reproducible.
- **Report, never auto-drop** — `minimal_feature_set(report)` is opt-in; the analysis never mutates the
  caller's table (pinned). Constant / NaN-heavy columns are excluded with a stated reason. The report is
  labelled dataset-specific (redundancy on one table is not a universal fact).
- Tests (`core`): `tests/test_feature_redundancy.py` — a known duplicate groups and keeps one; independent
  columns produce no groups (cry-wolf); transitive clustering; Spearman catches a square law Pearson
  misses; ontology-preferred representative; constant-column exclusion; minimal-set one-per-group; and the
  no-mutation contract. Prerequisite for the Feature Explorer.

## [1.6.176] - 2026-07-20
### Changed — **fit_frap_recovery decomposed by phase (206 → 109 lines), proven byte-identical.**
The FRAP recovery fit fused the hyperbolic curve fit, the normalisation-aware mobile-fraction derivation
(+ over-recovery warning), a residual-runs adequacy test, and a per-parameter identifiability assessment
(+ its warning) into one body dominated by measured rationale. Split into pure phase helpers:
- `_frap_derive_mobile` — the mobile fraction of the BLEACHED material that recovered (correct under
  pre-bleach normalisation, where `b - a` under-reported it) + the unphysical-plateau (b>1) warning;
- `_frap_identifiability` — the per-parameter 95% CI from the fit covariance, flagging any parameter
  whose interval is wider than its own value (the covariance is the only thing that knows whether the
  data constrains a parameter — R² cannot; the rationale moved here with it).
- **Behaviour-preserving, pinned as such.** `tests/test_frap_recovery_characterization.py` captured the
  fitted parameters, R², the mobile/immobile fractions, the over-recovery flag, the per-parameter CI
  widths and identifiability verdicts, and WHICH warnings fire across adequate / short-unidentifiable /
  over-recovery / too-few-points curves before the split and asserts them unchanged after; the existing
  `test_frap_fitting` passes unmodified. No number moved. Complexity ratchet 130 → 129; truncation guard
  allowlisted.

## [1.6.175] - 2026-07-20
### Changed — **fit_photobleaching decomposed by phase (233 → 65 lines), proven byte-identical.**
The exponential-bleach fit fused the curve fit, the tau confidence interval, the non-circular
observation-window adequacy metric, and its two-tier warning — most of its 233 lines being the measured
rationale for each (five failed single-number attempts, the circularity of checking against the fitted
tau, why two decay bounds are reported). Split into pure phase helpers, each carrying its own rationale:
- `_photobleach_tau_ci` — the 95% CI on tau from the fit covariance (the only evidence tau is determined;
  R² does not carry it);
- `_photobleach_window_metrics` — the two decay-observed bounds (no-floor lower bound; floor-subtracted
  upper bound), because no single scalar is both floor-robust and non-circular;
- `_photobleach_window_warn` — the two-tier warning (severe < 0.5 τ, mild 0.5–0.8 τ), measured on the
  decay actually observed rather than the fitted tau (which would be circular).
- **Behaviour-preserving, pinned as such.** `tests/test_photobleaching_characterization.py` captured the
  fitted parameters, R², the tau CI, both decay bounds, the correction factors, and WHICH warning tier
  fires across adequate / mid-window / short / flat synthetic movies before the split and asserts them
  unchanged after. The existing `test_photobleaching_window` passes unmodified. No number moved.
  Complexity ratchet 131 → 130; truncation guard allowlisted.

## [1.6.174] - 2026-07-20
### Changed — **fit_size_distribution_mle decomposed by phase (301 → 92 lines), proven byte-identical.**
The droplet-size-distribution identifier fused per-model MLE fitting, a Clauset power-law tail comparison
(with a seeded parametric bootstrap goodness-of-fit gate), a Vuong distinguishability test, and verdict
assembly into one 301-line body — a length at which the several statistical tests' interplay is
unreviewable. Split into pure phase helpers:
- `_fit_size_models` — per-model MLE (lognormal closed-form; gamma/weibull/exponential via scipy; the
  power law's Clauset x_min by KS minimisation) → the `models` table + `x_min`;
- `_powerlaw_tail_comparison` — the tail-only Vuong test that re-fits the best alternative on the SAME
  tail, gated by an absolute bootstrap KS goodness-of-fit test (so a locally-power-law-like tail of a
  lognormal/gamma cannot spuriously win);
- `_size_distinguishability` — the whole-sample Vuong test of best vs runner-up;
- `_size_verdict` — the human verdict, keeping the power-law claim scoped to its tail.
- **Behaviour-preserving, pinned as such.** `tests/test_size_distribution_mle_characterization.py`
  captured the selected model, every model's AIC/log-likelihood, the power-law x_min and its tail test,
  the distinguishability comparison, and the descriptive moments on lognormal and gamma samples before the
  split (the bootstrap is `default_rng(0)`-seeded, so the whole function is deterministic) and asserts
  them unchanged after. No number moved. Complexity ratchet 132 → 131; truncation guard allowlisted.

## [1.6.173] - 2026-07-20
### Changed — **classify_beads decomposed into its two classifier branches (306 → 68 lines), byte-identical.**
The VPT bead classifier fused two independent classifiers — a fast-template branch (ncc/snr/amplitude,
for large Airy-disk beads) and a Gaussian-fit branch (sigma/r²) — plus an empty guard into one 306-line
body. At that length the two schemes' interleaved thresholds are unreviewable. Split into named helpers:
- `_classify_fast_template` (+ its `_classify_fast_template_refs` reference-statistics phase — the NCC
  realness floor, singlet intensity, aggregate mass/amplitude gates, and dim/out-of-focus cutoffs);
- `_classify_gaussian_fit` (the sigma-based branch, R²-is-not-focus rationale preserved);
- `classify_beads` is now a 68-line empty-guard + dispatch on which metrics are present.
- **Behaviour-preserving, pinned as such.** `tests/test_classify_beads_characterization.py` captured the
  exact per-bead `bead_class` labels (the categorical output flips on any threshold drift), the
  `n_units_est` estimates, the dropped-rejected row count, and the recorded `classify_thresholds` on BOTH
  branches before the split and asserts them unchanged after. No number moved. Complexity ratchet 133 →
  132; truncation guard allowlisted with reason.

## [1.6.172] - 2026-07-20
### Changed — **partition_coefficient_local decomposed by phase (394 → 109 lines), proven byte-identical.**
The local-annulus partition-coefficient measurement — a 394-line science function whose per-droplet loop,
camera-floor logic, and six-branch verdict chain were fused into one body — is exactly what the
complexity budget warns about: at that length a silent failure path is invisible. Split into pure,
named phase helpers, none over 90 lines:
- `_pc_check_input` — the ABSOLUTE-intensity provenance gate (a preprocessed image makes Kp meaningless);
- `_pc_camera_floor` — the pedestal (`dark_reference` / extracellular median), with the "in vitro cannot
  be auto-detected by any method" refusal;
- `_pc_estimate_gap` — the interface-width annulus offset;
- `_pc_measure_droplets` — the per-droplet dense-vs-annulus measurement + the over-inclusive-mask CV
  warning;
- `_pc_verdict` — the reporting chain, including the two honesty rules (no validation claim next to a
  NaN; no confident "validated" when the mask is suspect).
- **Behaviour-preserving, and pinned as such.** A new byte-identity characterization test
  (`tests/test_partition_local_characterization.py`) captured the exact per-droplet and aggregate outputs
  across all five reporting branches (dark reference, extracellular, in-vitro-no-reference, raw-ratio,
  empty) plus the invalid-sample-type raise **before** the split, at `rel=1e-9`, and asserts them
  unchanged after — no floating-point operation was reassociated, no number moved. The complexity ratchet
  drops 134 → 133 (`_MAX_LONG_FUNCTIONS`), the win locked in downward.

## [1.6.171] - 2026-07-20
### Added — **Progress part 2, Part C: determinate bars over the core cell/condensate per-object loops.**
The analysis half of the progress work (1.6.132) covered the two zero-bar widgets and was upgraded to the
off-thread modal runner in 1.6.140. Part C is the piece left: the **core cell and condensate analyzers**,
whose per-cell loops make progress genuinely measurable, ran with **nothing on screen** during a
multi-second analysis.
- `cell_analysis_func` (per-cell contour/morphology loop) and `puncta_analysis_func` (per-cell puncta
  loop) gain `progress_callback(done, total)` — the `materialize_stack` signature, so `PhasedProgress` /
  the modal runner drive it; **`None` is a complete no-op**, so headless and batch callers are byte-
  identical (pinned in a test that compares the result with and without a callback). Batched to ~100
  updates so a repaint-per-cell can't dominate the runtime it reports.
- `run_cell_analysis_func` / `run_puncta_analysis_func` now run their compute through
  `run_with_progress` — the **modal, off-thread** runner (the 1.6.140 pattern) — so the countable loop
  shows a **determinate** bar and the window stays responsive; the napari layers are added back on the
  main thread. Headless (no QApplication) it runs synchronously with a no-op progress, so batch/tests are
  unchanged.
- `puncta_analysis_func`'s per-cell stat-writing block was extracted to `_store_cell_puncta_stats`
  (behaviour-preserving — the no-op-identity test covers it) to keep the loop within the review-length
  budget rather than raise the ceiling.
- Ratchet: `tests/test_progress_analysis_half.py` now lists the two tool functions (must accept
  `progress_callback`) and the two runners (must route through `run_with_progress`); a regression to a
  zero-feedback on-thread call fails. Functional proof in `tests/test_analysis_progress_callback.py`.
- **Honest limit** (same as part 1): this makes the wait VISIBLE and the window responsive, not the
  analysis shorter. **Needs an in-app glance** (viewer-coupled): confirm the modal bar advances during a
  real cell / condensate analysis and the result lands correctly.

## [1.6.170] - 2026-07-20
### Added — **Cohort selection: the histogram-bin and aggregate-row emitters — the two deferred targets.**
The `Cohort` selection target and `select_cohort` shipped in 1.6.151, with the comparative box/violin group
emitter; the histogram-bin and aggregate-row emitters were the clean follow-ons deferred with it. New
**`utils/cohort_targets.py`** delivers them as pure, GUI-free membership logic (the part that must be
correct, tested without a matplotlib event loop — exactly as the comparative emitter is):
- `bin_cohort(values, entity_ids, bin_index, bin_edges, …)` — the cohort of entities whose value falls in
  a histogram bin, membership matching the drawn range **exactly** (half-open bins, the **last bin closed**
  so the maximum lands in it, as matplotlib does), carrying the range as the definition
  (`"area ∈ [12, 18) µm²"`) so a dock can say *why* the objects are grouped.
- `aggregate_cohort(members, …)` — an aggregate row's contributing set, with the count stated
  (*"summarizes N objects"*), never one arbitrary member.
- `attach_histogram_brushing(…)` — wires a drawn histogram so a bar-click emits the bin cohort (returns an
  `emit_bin`/`apply_selection` handle, testable headless); `select_aggregate_row(…)` emits an aggregate
  row's cohort; `cohort_dock_label(…)` is the "N objects · why" caption.
- **Additive + selection≠filter**, both pinned: `select_cohort` fills `selected` with the members, so a
  cohort-unaware overlay highlights every member for free, and no emitter mutates the DataFrame or the
  analysed population. Tests: `tests/test_cohort_targets.py` (bin membership vs an independent recompute
  over every bin, last-bin-closed, aggregate count, round-trip through the real `SelectionService`).

These are the reusable emitters the shipped comparative case established; attaching them to a live
brushable histogram / aggregate-table dock is follow-on when those surfaces are built (none is in the tree
today — the spec asked to land bins/groups/aggregates first).

## [1.6.169] - 2026-07-20
### Added — **Biological QC Part B: the object-level flags now SURFACE — in the consolidated table and the QC report.**
The object-level QC module (`biological_qc_tools`, 1.6.152) computed edge/size/shape/intensity/containment
flags but exposed them nowhere a scientist would look. Part B wires them into the two places that matter,
and carries the cardinal contract — **flag, never filter** — through both: no row is ever dropped for
tripping a flag.
- **Consolidated long table** gains an additive `qc_flags` column (at the END of the schema, so every
  existing reader is untouched). `build_image_long_table`/`ConsolidatedLongWriter` compute the
  table-based flags (size/shape/intensity via robust MAD) from the wide object table, and **carry an
  upstream-stamped `qc_flags` through untouched** — that is how the mask-based flags (edge/containment),
  computed where the label image lives, reach the table. A comparison can now be recomputed with and
  without flagged objects — *"the effect holds when edge-touching cells are excluded"* is a stronger
  claim than an unqualified one. `qc=False` opts out; QC never breaks the keystone table build (a failure
  degrades to "no flags", never a lost row).
- **QC report** gains an object-level section (`qc_biological_objects`), appended by `run_full_qc` when an
  `object_table=` is supplied, in the same Assessment → Interpretation → Recommendation shape the imaging
  checks use. It states counts per flag: edge-touching is **definitive** (a truncated object is a
  measurement artefact — can read POOR); size/shape/intensity are **hints** worded as observations and
  never escalate past CHECK, because a mitotic or dead cell is real data. The QC UI wires the segmented
  cell/puncta table into the call so the section appears live; a malformed table degrades gracefully and
  never breaks the imaging report.
- Tests (`core`): `tests/test_biological_qc_surfaced.py` — seeded-outlier detection in the consolidated
  table, the **cry-wolf** clean-population test, the flag-never-drops-a-row contract (QC on/off give the
  same rows), upstream-flag carry-through, the report section's per-flag counts and definitive-vs-hint
  status, the empty-table N/A, and AST guards that the batch and QC UI actually wire it. Closes the Part B
  deferral recorded in 1.6.152.

## [1.6.168] - 2026-07-20
### Changed — **science_function_split: the 394-line MSD/α fit decomposed by phase, proven behaviour-preserving.**
Companion to the UI-builder split, governed by a stricter rule: **a numerical function may only be split if
a test can prove the numbers did not change.** `fit_anomalous_diffusion` (`condensate_physics_tools`) — the
MSD → D/α fit behind every viscosity number, and manuscript-facing — was the ideal first case: it already
carries 4 tests that assert its recovered values.

- Split **by computational phase** (validate → gate → fit → assess → package), never by line count, into
  pure helpers: `_lag_window_gate` (the defensible lag band), `_fit_msd_powerlaw` (the non-linear
  4·D·τ^α + 4σ_loc² refinement), `_assess_msd_identifiability` (the D/α confidence interval),
  `_classify_msd_motion` (R², fit quality, motion type), and `_package_msd_result`. The function dropped
  from **393 → 98 lines**, each helper well under the 120-line ceiling.
- **Every line was MOVED, not rewritten** — no floating-point operation reassociated, nothing "improved"
  while splitting. The proof: its 4 existing numerical tests (`test_msd_drift`,
  `test_msd_min_track_length`, `test_vpt_viscosity_chain`, and the route-equivalence viscosity chain)
  passed **unmodified**, and `test_no_undefined_names` confirmed no local was left unthreaded.
- The complexity ratchet `_MAX_LONG_FUNCTIONS` is lowered **135 → 134** (the ratchet moving down, which is
  it working), and a `_DELIBERATE` drop-guard record documents that the 75% shrink is a decomposition, not
  a truncation. **No numerical output changed anywhere.**

## [1.6.167] - 2026-07-20
### Added — **Publication figure refinement: refine the presentation, never re-run the analysis.**
A PyCAT comparative figure could be produced but not prepared for a journal — no export, no DPI, no vector
output — so adjusting an axis label or a colour meant re-running the analysis. That is slow and
scientifically wasteful: the numbers are already correct; only presentation needs work.

- New **`utils/figure_spec.py`** (`core`, Qt-free): separates a figure's **data** (`FigureData`) from a
  declarative **`FigureSpec`** (title, labels, limits, palette, fonts, size, DPI, n-annotation, caveat
  footnote). `render(fig_data, spec)` reads the data and applies the presentation; **mutating the spec and
  re-rendering never recomputes** — the plotted values are stashed on the figure so the contract is
  checkable.
- **The ontology supplies the defaults** (the payoff): the y-axis label defaults to *"Partition coefficient
  (dimensionless)"* (display name + units, no typing), and a measurement's caveats can render as a figure
  **footnote** — so the 2D-projection-proxy warning travels onto the figure instead of being lost between
  the analysis and the paper. Both are overridable per figure.
- **Export** (`export`) writes vector **PDF/SVG with fonts embedded as TEXT** (`pdf.fonttype=42`,
  `svg.fonttype='none'` — editors can adjust type, not outlines), a high-DPI PNG, the **summary DataFrame**
  alongside (a figure whose numbers are not saved is irreproducible), and the **spec as JSON** so the figure
  regenerates identically. **Size presets** (single / 1.5 / double column) are offered as *sizes*, never as
  journal-compliance claims (requirements vary; unverifiable compliance is worse than sensible defaults).
  Palettes are colour-blind-safe (Okabe–Ito) by default.
- New **`tests/test_figure_spec.py`** (`core`, matplotlib Agg): the **refine-never-recompute contract**
  (changing the spec leaves the plotted values byte-identical); the spec round-trips through JSON; labels
  and units default from the ontology (and an explicit label overrides); caveats render as a footnote
  matching the ontology; and export produces vector output with text fonts, a PNG at the requested DPI, the
  summary CSV, and a regenerating spec JSON.

## [1.6.166] - 2026-07-20
### Added — **PyCAT Validation Suite: a standing per-release regression benchmark, with the first baseline recorded.**
The test suite answers *"did anything break?"* per commit. At PyCAT's release cadence (often several
versions a day) that misses the question that catches slow degradation: *"is segmentation quality on our
canonical cases the same as it was ten releases ago?"* A change can keep every test green while moving Dice
from 0.94 to 0.89, and nobody notices until a result looks wrong months later.

- New **`benchmarks/`** (outside `tests/`, so it never runs on the per-change loop): `cases.py` — a fixed,
  seeded canonical case set (puncta, condensate/partition, cells) with **constructed** ground truth (never
  produced by a PyCAT run, so the suite cannot track a drifting method as "stable"); `run_suite.py` —
  measures each case against ground truth via `benchmark_tools` (Dice/IoU, matched-detection F1,
  object-count error, derived measurements like the partition coefficient, and runtime) and **appends** one
  record per version to `results.jsonl` (append-only — it diffs cleanly and never rewrites history).
- **`compare_to_baseline`** fails on a metric moving beyond a **declared, justified** tolerance in the
  *worse* direction (Dice ±0.02 covers seeded-RNG variation; a >5% partition-coefficient move is real),
  **reports — never fails on — an improvement** (an unexplained improvement often means the case or ground
  truth changed), and treats runtime as **advisory** (machines vary). Tolerances are never tuned to make a
  run pass — a metric beyond tolerance is a finding.
- **The first baseline is recorded** in `benchmarks/results.jsonl` (v1.6.166): puncta Dice 0.953, partition
  coefficient recovered at 5.0, cells Dice 1.0 — the deliverable that makes future cross-release comparison
  possible. Run it with `python -m benchmarks.run_suite <version>` (documented in `benchmarks/README.md`);
  a new `benchmark` pytest marker is registered.
- New **`tests/test_validation_suite.py`** (`core`, machinery only — the metric gate itself runs
  deliberately, not per-commit): the suite runs end-to-end and writes a well-formed record; a case is
  deterministic under its seed; the comparator flags an injected regression, reports an improvement without
  failing, and treats a higher count-error as a regression while runtime stays advisory; and the results
  file is strictly append-only.

## [1.6.165] - 2026-07-20
### Added — **Measurement Reliability Index: every reported number carries a decomposable reliability score.**
The roadmap's unifying construct, buildable now that all its inputs exist — imaging QC, biological
plausibility, parameter sensitivity, benchmark agreement, control separation, and calibration validity.
**This is composition, not new science:** every factor comes from a module that already measures it;
inventing a new heuristic here would be unvalidated and would undermine the score.

- New **`utils/reliability.py`** (`core`, pure): a frozen `ReliabilityScore` (value 0..1, grade, per-factor
  `contributions`, worst-first `reasons`, and an explicit `missing` list) and `reliability(measurement_key,
  *, image_qc, object_flags, calibration, sensitivity, benchmark)` composing the available signals.
- **Five honesty rules, enforced and tested.** (1) **An unmeasured factor is never treated as passing** —
  it goes in `missing` and does not contribute (silently assuming "fine" would make every score
  optimistic). (2) The score is **decomposable** — `value` is the product of the `contributions`. (3)
  `reasons` are **worst-first** and concrete. (4) **Missing core evidence (QC or calibration) caps the
  grade below `high`** — absence of evidence is not evidence of reliability. (5) A **refused calibration is
  a hard override to `unreliable`** — a number computed under an invalid calibration is not a weak
  measurement, it is not a measurement.
- **Stated aggregation:** the product of factor scores (each 0..1), explainable in a Methods section — not
  a tuned ML-ish blend. Adapters read each factor from its own module (`_score_qc` from `run_full_qc`,
  `_score_object_flags` from `biological_qc`, `_score_calibration` from `check_calibration_validity`,
  `_score_sensitivity` from `measurement_stability.stability_factor`, `_score_benchmark`). Scored for the
  partition/concentration/ΔG family first; `format_with_reliability` extends the `Parameter` display
  (`K_p = 4.2 (reliability: moderate)`).
- Reliability is **reported, never a silent filter** — the user decides (the biological-QC contract).
- New **`tests/test_reliability.py`** (`core`): a fully clean measurement scores `high`; **each factor
  degraded individually lowers the score and names itself** (one case per factor); missing core factors cap
  the grade and are listed (a supplied-but-all-`na` factor counts as missing, not passing); the value
  decomposes as the product of contributions; reasons are worst-first; and a **refused calibration scores
  `unreliable`, not merely low.**

## [1.6.164] - 2026-07-20
### Added — **Explicit 2D / 3D / time-series condensate modes: refuse the volume-fraction approximation, label everything.**
`invitro_fluor_ui` already prints *"area fraction=… (2D projection, not a volume fraction)"* — but that
honesty lives in a transient napari message while the number travels into tables, the consolidated long
table, and figures with no qualifier. A projected area fraction is not a volume fraction (their ratio
depends on object size/shape/axial overlap), and the same workflow is applied to 2D fields, z-stacks, and
time series where some measurements valid in one are meaningless in another.

- New **`toolbox/condensate_modes.py`** (`core`, pure): a `CondensateMode` enum (`FIELD_2D` / `ZSTACK_3D` /
  `TIMESERIES`) and `resolve_condensate_mode` — declared or derived from the data, **never silently guessed
  for an ambiguous 3D array** (z-stack vs time series have different valid measurements, so it refuses and
  points at the loader's disambiguation rather than guessing).
- **Refuse rather than convert:** `volume_fraction` returns `nan` **with a stated reason** in 2D (a single
  plane cannot measure it, and converting the projected fraction needs assumptions — mono-disperse spheres,
  no axial overlap — the data cannot support), and the true value from voxels in 3D. **The 2D numbers are
  unchanged — this is labelling and gating, not recomputation.**
- **The qualifier travels:** `attach_mode_column` stamps a `condensate_mode` column on every emitted table,
  so a projected fraction is never mistaken for a volume fraction downstream. The projection caveat is
  already data in the measurement ontology (queryable, renderable in a figure footnote), not only a UI
  string.
- **Time-series independence:** `mark_timeseries_as_unit` declares a per-frame series ONE biological unit,
  so the comparative-figures replicate aggregation (`aggregate_to_unit`) collapses it to one rather than
  counting each frame as an independent replicate — reusing the existing pseudoreplication machinery.
- New **`tests/test_condensate_modes.py`** (`core`, synthetic): the 2D volume-fraction refusal (NaN +
  reason); a 3D z-stack of known spheres whose true volume fraction differs materially from the projected
  area fraction; ambiguous-3D refusal; the mode column on tables; the ontology caveat; and a 20-frame
  series aggregating to one unit.

## [1.6.163] - 2026-07-20
### Added — **Background-mode guardrail: turn the partition-coefficient docstring reasoning into a check at the moment the mistake would be made.**
`client_enrichment` already supported three background treatments (scalar offset, signal-free-region mask,
local dilute shell) and its docstring carries careful reasoning — but **nothing surfaced any of it**, so
every GUI partition coefficient used `background=0.0`. Two consequences: users with a real dark reference
could not use it (their K biased toward 1), and users who *think* they should subtract "the area outside
the condensate" had no guidance that doing so destroys the measurement. The second is a mistake a
well-intentioned user makes naturally, and the code already knows why it is wrong.

- **The guardrail** (`assess_background_region`, wired into `client_enrichment`): when a background region
  is supplied, its mean is compared against the dilute-phase mean; if the region is not meaningfully darker
  (it is plausibly dilute phase, not background), a warning fires that states the **consequence** — *"if
  this region is inside the cell, subtracting it will subtract the dilute phase from itself and DESTROY the
  partition measurement; a background region must be outside the cell or a dark frame."* It warns, never
  blocks (the user may have a valid unusual case), reusing the existing napari warning path.
- **The choice travels with the result:** `client_enrichment` now emits `background_mode`
  (`none`/`scalar`/`region`), `background_source`, and `background_warning` — so a K computed with a
  dark-frame offset and one computed raw are distinguishable in the table (and the consolidated long
  table). A K computed raw and one offset-corrected are different measurements. **Default stays `none`**, so
  existing workflows are unchanged.
- **Ontology caveat:** the "dilute phase is NOT background / the only legitimate offset is the instrument
  offset" reasoning is now a caveat on `partition_coefficient` in the measurement ontology, making it
  available to figure footnotes.
- New **`tests/test_background_mode.py`** (`core`, synthetic): each mode produces the expected offset; a
  dilute-phase "background" region triggers the consequence-stating warning while a genuinely dark region
  does not; a region offset recovers the true K across a camera pedestal; and the mode/offset/source travel
  with the result. (`test_calibration` updated for the three new provenance keys — no calibration keys leak
  without a curve.)

## [1.6.162] - 2026-07-20
### Added — **Analysis presets: "reasonable starting parameters" as a declared, inspectable object that can't smuggle an unaudited default past a user.**
Sensible defaults are scattered across signatures, docstrings, and the maintainer's head; a new user
opening a condensate workflow faces a dozen parameters with no idea which suit their data. This makes a
preset a declared, versioned bundle — with two non-optional honesty invariants.

- New **`utils/analysis_presets.py`** (`core`): a frozen `AnalysisPreset` (key, applies_to, parameters,
  provenance, validated, validation_ref, requirements, caveats) and a **sparsely, honestly seeded**
  `ANALYSIS_PRESETS` registry — only instrument/sample combinations actually run (a validated condensate
  SNR gate, VPT bead tracking, an in-vitro 63× confocal starting point). An invented preset for unused
  hardware carries false authority and is not seeded.
- **`provenance` is mandatory and non-empty** (enforced at import) — a preset with unstated provenance is
  just a hidden default with a friendly name, which is exactly what the filter-sensitivity programme exists
  to expose. **`validated=True` requires a linked `validation_ref`** into `VALIDATED_CASES`, so the flag
  cannot be decorative — it means the set passed the sensitivity harness, not that it looked reasonable.
- **Drift guard:** `orphan_parameter_keys` checks every preset's keys against the REAL parameter names of
  the workflow it claims (read from the live function signatures), so a preset that rots out of sync with
  its function fails a test.
- **Populate, never lock:** `PresetApplication` seeds the values, lets the user change any, and reports
  "modified from <preset>" once edited (a result from a modified preset is not the preset's result) —
  recording the applied preset + modification state into the workflow. **Requirement gating reuses
  `operation_spec.runnability`** (the single requirements vocabulary), never a second gate.
- New **`tests/test_analysis_presets.py`** (`core`): unique keys + mandatory provenance; the drift guard;
  `validated⇒ref` (and the import-time refusal of an unlinked one); populate-not-lock deviation tracking
  and recording; and requirement gating with a stated reason.

## [1.6.161] - 2026-07-20
### Added — **Per-feature provenance: attach the workflow chain to each measurement, not just the session.**
`batch_processor` records a complete, replayable workflow — but that chain is attached to the *session*,
not the *measurement*. A 40-column table with a 12-step workflow gives no way to know that
`partition_coefficient` depended on steps 3, 5 and 9 but not on the fibril segmentation in step 7. A table
opened in six months carries its values but not the route to them, and reproducibility is the manuscript's
central claim.

- New **`utils/feature_provenance.py`** (`core`, pure): a frozen `FeatureProvenance` (feature, operation_id,
  input_layers, step_indices, parameters, software, acquisition) **composed from existing sources — never a
  second recording mechanism** that would drift from `batch_processor`. `software_versions()` and
  `acquisition_from_metadata()` capture the environment and `metadata_extract` fields automatically.
- **"All steps" is not provenance.** `trace_step_indices` walks the layer LINEAGE backward and reports only
  the steps that actually produced the feature's ancestors — so a feature from one workflow branch does NOT
  claim an independent branch's steps. When the lineage cannot discriminate (the layer is unrecorded), it
  returns `None` with a reason, never a useless "everything". **Absent beats guessed**, consistent with the
  layer-tag hook's `derived`/`inferred` distinction.
- **Surfaces** (Part D): a sidecar `<name>_provenance.json` keyed by column (not 40 unreadable extra CSV
  columns) via `write_provenance_sidecar`; the consolidated long table already carries `operation_id` among
  its provenance columns; and a `describe_provenance` "where did this number come from?" query that reads
  the chain. Capturing provenance never touches a computed value.
- New **`tests/test_feature_provenance.py`** (`core`): the discrimination test (a two-branch workflow — a
  branch-A feature does not list branch-B's steps); an unrecorded lineage → `None` + reason, not "all
  steps"; fields composed not fabricated (underivable → absent); software/acquisition captured
  automatically; and the sidecar round-trips keyed by column.

## [1.6.160] - 2026-07-20
### Added — **Per-measurement parameter stability: "if I nudge this parameter, does the number I report change?"**
`benchmark_tools` sweeps a parameter and compares the resulting **masks** (Dice/IoU/F1). It does not report
how much each *derived measurement* moves — and that is the scientifically load-bearing distinction: two
settings can produce masks that agree at Dice 0.95 while the partition coefficient computed from them
differs by 40%, because a small boundary shift moves the dense/dilute split. Mask agreement is not
measurement agreement, and it is the measurement that gets published.

- New **`toolbox/measurement_stability.py`** (`core`, pure): `measurement_stability(image, method, param,
  sweep, measure_fn)` runs the full chain (segmentation → measurement) across a **plausible** parameter
  range and returns a `StabilityResult` per measurement — baseline, per-setting values, `relative_range`
  ((max−min)/|baseline|), and a verdict. Segmentation reuses `benchmark_tools.run_candidate` (no second
  runner to drift).
- **Two traps encoded.** *Population change vs measurement change:* `n_objects` is reported alongside, and
  when the object count changes materially across the sweep the verdict is **`population-change`** ("a
  shifting measurement here reflects a DIFFERENT POPULATION, not instability") rather than confidently
  calling the measurement unstable. *A near-zero baseline* returns `nan` with a stated reason, never an
  infinite relative range.
- **Stated convention, not a fitted quantity:** the verdict thresholds (<5% stable, 5–20% sensitive, >20%
  unstable) are documented as a convention.
- **MRI adapter** (`stability_factor`) maps the verdict to a 0..1 parameter-sensitivity factor for the
  Measurement Reliability Index (`nan` = "cannot assess", never treated as reliable) — the classification
  lives here once, not duplicated in the index. Plus a **report figure** (measurement/baseline vs
  parameter) that reads the measurement ontology's display name and units.
- New **`tests/test_measurement_stability.py`** (`core`, synthetic, known-answer): a stable total-intensity
  measurement reads `stable`; a partition coefficient whose boundary the threshold decides reads
  `sensitive`/`unstable`; a count-changing sweep is a **population change**; `relative_range` is scale-free;
  and a zero baseline is `undefined`, not a divide-by-zero.

## [1.6.159] - 2026-07-20
### Added — **Route equivalence increment A: the matrix grows from three to six canonical workflows, plus metadata comparison.**
The cross-route equivalence matrix asserts the same workflow yields the same numbers through every route it
can run (headless / batch replay / session reload) — a divergence is the highest-severity reproducibility
bug PyCAT can have (the rolling-ball precedent: batch once passed a normalised image where interactive
passed raw counts). It shipped at three workflows by design; this adds the next three, the audit's
"beginning of the most important validation program in PyCAT." **Test-only; no divergence was found.**

- **Cellpose segmentation** (headless ≈ batch ≈ session) — the most-used path and the most parameter
  surface. Genuinely drives all three routes (torch present); the batch route runs the REAL
  `replay_cellpose_segmentation`, so it proves the batch replay reads the recorded diameter and resolves
  the right layer rather than falling back to defaults. Gated on cellpose being importable (the
  optional-dependency skip pattern) rather than weakening the assertion.
- **Colocalization** (headless ≈ session; batch a documented gap) — two-channel input exercising channel
  assignment (m1 = ch1-over-ch2 ≠ m2), driven through the real Manders and Pearson functions. Batch is a
  declared gap: no colocalization step exists in the replay registry.
- **Time-series condensate partition** (headless ≈ session; batch a documented gap) — a per-frame partition
  series over a stack through the real `client_enrichment`. Batch is a declared gap: a time-series is not a
  per-image batch step (same class as the VPT/MSD skip-stub).
- **Beyond the arrays — metadata comparison** (`compare_metadata` on `Workflow`, `compare_frame_metadata`):
  the two new DataFrame workflows now also compare schema (column order), dtype kind, **NaN policy** (a
  route emitting 0.0 where another emits NaN is a real divergence), and the **units column** — because two
  routes can produce numerically similar tables while differing in scientifically important metadata.
  Existing workflows pass `None` and are unaffected. Layer-tag/provenance comparison is a noted next step,
  deferred (the routes return arrays/frames, not tagged layers).
- The batch **gaps are declared, not skipped silently** — the harness still fails if a gap closes or a
  route vanishes without the matrix docstring being updated. Two fixture bugs surfaced and were fixed
  during construction (a division-by-zero from equal dilute/pedestal backgrounds, and a shared RNG that
  advanced between a route's reference call and its session re-call) — both in the test fixtures, not
  production. Test-only.

## [1.6.158] - 2026-07-20
### Validated — **Filter sensitivity increment 4: the puncta refinement gate cluster (no new inverter; one gate added, one confirmed inert).**
The puncta refinement gate applies `kurtosis_threshold=-3.0, local_snr_threshold=1.0,
global_snr_threshold=1.0` **together**, and it decides which puncta exist — every downstream count,
density, partition coefficient, and colocalization statistic inherits its decisions. This SNR family
already produced a proven inverter (the un-subtracted `object_mean/bg_std` ratio, fixed 1.6.86), so it is
the highest-value untested group. Test-first: a divergence would be a finding, not a tolerance to tune.

- **`local_snr_threshold` / `global_snr_threshold` — validated on all three signatures.** Both are
  contrast-to-noise (background-subtracted), so they are **offset-invariant** (a camera pedestal cancels)
  and **scale-free** (a pure intensity ratio carries no pixel units, unlike the ring geometry of increment
  2's scale case). Swept across the plausible range on a brightness-spanning population of clearly-real
  puncta, the survivors' mean brightness **does not drift** — no selection bias (the mechanism that made
  `r2_min` report a mean of 77 against a true 44). `segmentation.global_snr_threshold` is added to
  `VALIDATED_CASES` (the local sibling was added in increment 2).
- **`kurtosis_threshold=-3.0` — confirmed INERT, documented-absent.** scipy Fisher (excess) kurtosis has a
  hard floor of −2, so `kurtosis < -3.0` can never be true and the gate rejects nothing. It therefore
  **cannot be one arm of a two-parameter interaction cliff** — the joint kurtosis × local_snr grid is flat
  along the kurtosis axis. An inert gate has no bad control, so (like `bleach_r2_min`) it stays out of the
  registry, pinned instead by a test that fails if the kurtosis computation ever changes to be able to
  fire. A **secondary finding** is recorded: pushed into a firing range (e.g. 0.0), the kurtosis gate
  becomes brightness-selective (a faint object's local patch is less peaked), dropping the dimmest puncta
  first — which is exactly why the shipped default is set below the −2 floor to be inert.
- New **`tests/test_filter_sensitivity_puncta_cluster.py`** (`core`, synthetic, drives the real
  `_snr_conditions` and `stats.kurtosis`): offset, scale, and selection-bias sweeps for both SNR
  thresholds; the joint kurtosis × SNR interaction grid; and the kurtosis-inertness pin. **Test-only — no
  production behavior changed.**

## [1.6.157] - 2026-07-20
### Added — **QC for scan-acquisition aberrations: per-object motion shear, bidirectional phase, disk pattern, pinhole crosstalk.**
`data_qc_tools` covers saturation/focus/SNR/drift/vibration/aberration — every check asks about the image
as a whole or the optics. **None asked "was this OBJECT distorted by the way the pixels were collected?"**
The motivating case: on a laser-scanning confocal, a *mobile* condensate is torn/sheared because it moves
during the raster, while a *stable* one in the same frame is clean — so the same image contains trustworthy
and untrustworthy objects, and every existing whole-frame QC check passes it.

- New **`toolbox/scan_qc_tools.py`** (`core`, synthetic-tested):
  - **`qc_scan_shear`** (per object) — fits each object's intensity-weighted column centroid against row
    index (the slow-scan axis); a mobile object's centroid drifts systematically with row. **The in-frame
    control is the method:** each object's slope is compared against the others in the SAME frame, not a
    fixed global threshold (which varies with sample/scan-speed/zoom). If objects shear together it is
    **stage drift / sample flow**, not per-object motion, and is reported as such. An elongated tilted
    static object (whose centroid slope is morphology) is reported **`ambiguous`**, never confidently
    called motion; objects too small to fit a slope are `na`. A **velocity is reported only when the line
    time is known** — otherwise an honest px/row, never converted with an assumed line time.
  - **`qc_bidirectional_phase`** — cross-correlates odd-row vs even-row sub-images; a lateral offset is a
    bidirectional-scan comb artifact (a scanner phase-calibration problem).
  - **`qc_disk_pattern`** — spinning-disk pinhole striping as a **detrended** spectral peak. It reuses
    `qc_vibration`'s recorded lesson: a low-frequency vignetting gradient reads as periodic striping unless
    removed first, so the field is detrended before the spectral test.
  - **`qc_pinhole_crosstalk`** — elevated local background around bright objects vs distant background;
    warns that partition coefficients and enrichment ratios (what it most corrupts) will be biased.
- **Gated by modality, never guessed from pixels** (`run_scan_qc`): scan shear applies only to a
  point-scanner, disk pattern only to a spinning disk; on the wrong modality — or an unknown one — each
  reports `na` with the reason (a confident wrong verdict is worse than "not assessed"). Wired into
  `run_full_qc` behind optional `labels` / `modality` / `line_time_s` (appended only when a modality is
  given).
- **Per-object shear flags compose with biological QC** — `scan_shear_flags` returns a per-label Series
  that `biological_qc(..., scan_shear_flags=)` folds into the same flag columns and summary. Flag, never
  filter — so a condition comparison can be recomputed excluding motion-corrupted objects.
- **Metadata**: `metadata_extract` now carries `acquisition_mode` / `line_time_s` / `dwell_time_s` /
  `pinhole_um`, filled opportunistically and format-agnostically from the raw block (`None` unless a raw
  key plausibly names it and the value parses — a guessed scan mode is exactly what the gating refuses).
- New **`tests/test_scan_qc.py`** (`core`, synthetic — no microscope): the measured slope recovers the
  injected displacement-per-line; **one stable + one sheared object in a single frame flag exactly one**
  (the motivating case); uniform shear → drift, not per-object motion; a tilted elongated static object is
  `ambiguous`, not motion; bidirectional offset recovered; disk periodicity detected while a smooth
  vignette is not (the detrending test); a crosstalk halo raises the metric and a clean field does not;
  gating returns `na` with a reason on unknown modality; and the shear flag composes with biological QC.

## [1.6.156] - 2026-07-20
### Added — **Positive/negative control validation: "does my segmentation actually work on my data?", answered with the user's own controls.**
`benchmark_tools` scores candidates against a ground truth *within one image*. It cannot answer the
question a reviewer asks: does the method detect the objects in a **positive control** (known to contain
them) *and* detect **nothing** in a matched **negative control** (untransfected, no-primary, dye-only)? A
segmentation can score well on ground truth and still fire on empty fields — the false-positive rate on a
negative control is the number that tells a reviewer the detections are real.

- New **`toolbox/control_validation.py`** (`core`, pure): `validate_against_controls(positive, negative,
  method, param_grid)` sweeps one method across a parameter grid on BOTH controls with identical settings
  and returns a per-setting DataFrame (`ControlResult`: n_positive, n_negative, false_positive_rate,
  positive_density, separation, verdict + a **stated reason**). Scoring **reuses `benchmark_tools`**
  (`_labelled`, `basic_metrics`) — no parallel implementation to drift.
- **`recommend_parameters`** returns the setting that maximizes positive detection **subject to the
  negative control staying near zero** — not the most detections outright. **When no setting separates the
  two, it returns `None` and warns with the reason** — *"no parameter set distinguishes your positive from
  your negative control"* is a real finding about the ASSAY, not the software. A least-bad setting is never
  returned in its place (that would launder an assay problem into a software recommendation).
- **Honest edge handling:** mismatched acquisition between the two controls (exposure/gain/laser, via the
  calibration module's `AcquisitionFingerprint`) **warns loudly** — an intensity comparison across
  mismatched exposures is meaningless. Counts are **density-normalized** (objects/µm²) through a real pixel
  size so different field sizes are comparable; without a pixel size the density is left NaN, never faked
  to 1.0 (the pixel-size gate). The negative control's expected count is **user-declared** (default 0), not
  assumed zero — a legitimate autofluorescence baseline is not flagged as false positives.
- **Report artifact** (`control_report_figure`): detections vs the swept parameter for both controls on one
  axis, the recommended operating point marked and its separation stated — the supplementary figure behind
  *"parameters were chosen to maximize detection in positive controls while yielding <1% detections in
  matched negative controls."*
- **UI:** Toolbox → Image Processing → **Control Validation (positive/negative)** — pick the positive and
  negative image layers, sweep a threshold, see the recommendation (or the refusal) and the report figure.
- New **`tests/test_control_validation.py`** (`core`, synthetic): a recommendation recovers ~N objects with
  ~0 false positives; **the refusal case** (indistinguishable controls → `None` + reason — the most
  important test); mismatched acquisition warns; density is field-size independent; and a declared
  non-empty negative baseline is honored rather than flagged.

## [1.6.155] - 2026-07-20
### Fixed — **Explicit operation context: off-thread execution was silently degrading layer op tags from definitional to guessed.**
The layer-tag hook attributes each new layer to the operation that made it. Its mechanism —
`_op_from_stack`, a walk up the call stack for a decorated function carrying `__pycat_op__` — only fires
when that function is STILL ON THE STACK as the layer is created. **The off-thread execution shipped in
1.6.139/140 (`operation_runner`) breaks that:** the compute frame has already returned by the time the
result callback creates the layer, so the walk finds nothing and the op silently degrades from
definitional (`source='derived'`) to a name-substring guess (`source='inferred'`) — quietly, and worse as
more widgets adopt the runner. This replaces the implicit walk with an explicit context; the walk stays as
a fallback.

- New **`operation_context(op)` / `active_operation()`** in `utils/tag_registry.py` — a `contextvars`
  context declaring the operation responsible for layers created inside a block. **`contextvars`, not a
  module global:** a global would leak an op across threads and mis-attribute a layer created on another
  thread, which is strictly *worse* than an absent tag — it violates the hook's own "an absent tag is
  honest; a guessed one is a lie" principle. Thread-isolated by default; propagates into `asyncio`.
- **Hook resolution order** (`layer_tag_hook`) is now explicit → stack walk → name guess → absent. The
  explicit context is preferred (definitional, `source='derived'`); the stack walk is unchanged as the
  fallback for un-migrated synchronous paths; name inference still marks itself `source='inferred'`.
- **`@tags_layer`** now sets `operation_context(op)` around the wrapped call, so every decorated function
  *synchronously* creating a layer is covered with no call-site change.
- **`operation_runner.execute`** captures `active_operation()` at call time and re-establishes it around
  `on_result`, so a layer created in the result callback is attributed to the operation that produced the
  data — the fix for the concrete breakage. Benefits every current and future runner adoption.
- New **`tests/test_operation_context.py`** (`core`): the context sets/nests/restores (and restores on an
  exception); a layer made inside it is `derived`; **a layer made in an `operation_runner` result callback
  is tagged definitionally** (the regression — it fails on the pre-fix tree); the op does not leak across
  threads; and the stack walk + name inference are unchanged (fallback intact, a guess still a guess).
- **Additive and back-compatible** — the stack walk is retained, so no un-migrated path regresses; the
  `derived` vs `inferred` distinction (a fact vs a guess) is preserved exactly.

## [1.6.154] - 2026-07-20
### Added — **Measurement ontology: what each measurement MEANS, machine-readable and guarded against drift.**
`utils/measurement.py` already models a measurement's *value* (`Parameter`: units, uncertainty, source).
This adds the missing *definitional* side — what a measurement means, the equation behind it, and where it
comes from — the metadata a Methods section or figure legend needs, which today lives only in scattered
docstrings. **Additive: no computation changes.**

- New **`utils/measurement_ontology.py`** (`core`, pure): a frozen `MeasurementDef` (key, display_name,
  definition, equation, units, interpretation, caveats, reference, doi) and `MEASUREMENTS` — a registry
  seeded with 12 entries that are scientific *claims* (partition_coefficient, client_enrichment,
  delta_g_transfer, viscosity, D_um2_per_s, alpha, mobile_fraction, t_half, manders_m1/m2, pearson,
  projected_area_fraction). Plus `describe(key)` and `units_for(key)`.
- **Transcribed, never invented.** Every definition/equation/units is transcribed from an existing PyCAT
  docstring or the code itself. A `reference` is set only where the citation is certain (only `viscosity`,
  → Stokes–Einstein); unsourced fields are left `None` for a domain expert — *a wrong equation or DOI in a
  registry destined for a Methods section is worse than an absent one.* Keys are the EMITTED column names
  (`D_um2_per_s` not "diffusion_coefficient"; `projected_area_fraction`, flagged as a 2D projection proxy,
  not a true volume fraction).
- New **`tests/test_measurement_ontology.py`** (`core`): the load-bearing **units-agreement** test —
  constructs the real emitter (`delta_g_transfer(10, 1, 298)` → a `Parameter`) and asserts its emitted
  units equal the ontology's, so a units claim cannot silently drift from the code. Plus: every
  `emitted=True` key must appear in `src/pycat` (no aspirational entries), every `reference` must carry an
  equation (no orphan citations), and every entry is well-formed.
- **Consumer:** `comparative_figures.condition_comparison` now attaches the measurement's display name,
  units, definition, and caveats to the returned `summary.attrs`, and labels the y-axis
  `"{display_name} ({units})"` when the ontology knows the measurement — so a figure reads its own legend.
- **Deliberately NOT** Methods-section generation or a Measurement Reliability Index (both build on this),
  and NOT a duplication of scikit-image's `regionprops` geometry docs (plain geometry is delegated there).

## [1.6.153] - 2026-07-20
### Added — **CZI seam regression test: turn the reported mosaic discontinuity into a measurable number.**
The one prior-audit priority carried across three consecutive audits without closing — because *"a visual
bug with no measurement is a bug that cannot be closed."* Both reviews said the CZI work was
"architecturally improved but not validated against the reported defect" (a left-side column
discontinuity from a mis-assembled mosaic tile). `test_czi_bioformats_reader.py` had no seam assertion, so
CI could not notice the defect returning — or tell whether it was already gone. This is the measurement.
**It measures the seam; it does not fix it.**

- New **`file_io/czi_seam.py`** (pure numpy, `core`): `column_seam_score(frame, x)` — a boundary's step
  as a z-score against its NEIGHBOURS (normalized, not an absolute threshold, since absolute pixel steps
  vary with sample/exposure), and `persistent_seam_columns(frames)` — the columns anomalous on a MAJORITY
  of frames. That many-frame test is the key insight: real image structure moves frame to frame, a tile
  seam does not, so a boundary anomalous at a *fixed* column on *every* frame is a seam, not content.
- New **`tests/test_czi_seam.py`** (`core`): a clean synthetic mosaic scores no seam (the metric doesn't
  cry wolf); a deliberately offset mosaic scores high at *exactly* the injected boundary; the seam is
  persistent across frames while structure is not; and the score is normalized (a 20× contrast frame
  still scores no seam). Plus an **opt-in real-file assertion** — gated on `PYCAT_CZI_SEAM_FILE`, it reads
  frames through PyCAT's real CZI path and asserts no persistent seam. (The large real file cannot live in
  the repo; this is the only thing that confirms the *reported* defect is gone — run it with the file.)
- New **`scripts/czi_diagnostics.py`** (run-once, not CI): the measurements the audits asked for — read
  latency under forward/random/alternating access, cache hit-rate + planes cached, a staleness read-back
  check, and per-boundary seam scores for a frame sample.

Full `pytest -m core` green (1132). **Note:** the CI assertions prove the metric works and the synthetic
defect is caught; **the reported defect can only be closed by running the opt-in test / the diagnostics
script against the real file** — the number then either closes it or reopens it as a fix-spec with evidence.

## [1.6.152] - 2026-07-19
### Added — **Biological QC: an object-level QC layer — flag biological outliers, never filter them.**
PyCAT's imaging QC answers *"can I trust this image?"* (saturation, focus, SNR, drift). Nothing answered
*"can I trust this object?"* — yet the most common analysis errors are object-level and pass imaging QC
perfectly: a cell truncated by the field edge, an oversegmented nucleus, a condensate outside its cell,
an object of extreme size or intensity. Each silently biases a population statistic.

New **`toolbox/biological_qc_tools.py`** (headless, `core`-testable):
- `flag_edge_touching(labels, border_px=)` — objects whose mask reaches the field edge (truncated →
  area/shape/total-intensity wrong). The one flag stated **definitively** — it's a measurement artefact.
- `flag_size_outliers` / `flag_shape_outliers` / `flag_intensity_outliers` — **robust (median/MAD)**
  outlier detection, so the outliers can't corrupt the estimator that finds them; `k` is a declared
  parameter recorded on the result.
- `flag_containment_violations(child_table, parent_labels)` — a child whose centroid is outside any
  parent object (a condensate not in a cell is usually a segmentation error).
- `biological_qc(table, labels, parent_labels=)` — returns the table with the flag columns + a per-object
  `qc_flags` summary string + a per-flag count report on `.attrs['qc_report']`.

**The cardinal rule: flag, never filter.** Excluding objects is the user's explicit decision — the
no-silent-gates contract, and the exact failure the filter-sensitivity programme exists to catch. Flags
are worded as **observations** ("touches image border", "unusual size"), never verdicts ("bad cell") — a
mitotic or dead cell is real data. `biological_qc` **never removes a row** (pinned in a test).

Tests (`core`): `tests/test_biological_qc.py` — the injected-outliers-only detection, the **cry-wolf**
test (a clean population flags nothing), the **MAD-robustness** test (added outliers don't move the
inliers — what mean/SD gets wrong), containment, and the flag-don't-filter row-count contract. Full
`pytest -m core` green (1132).

**Deferred** (Part B, honest friction): surfacing the flags in the consolidated long table and the QC
report. The flags need a **label mask** at compute time, which the consolidated table's stream-from-CSV
path does not carry, so the computation must be wired upstream where the mask exists; the QC-report
section lands in the 1760-line `data_qc_tools`. The module is the reusable core those integrations consume.

## [1.6.151] - 2026-07-19
### Added — **Cohort selection: a GROUP as a typed selection target, so histogram bins / box-violin groups / aggregates select honestly.**
The top deferred-interaction item, and the prerequisite comparative-phenotyping increment 3 was blocked
on. A selection could only be a *set of entity ids*, so a histogram bar or a box/violin condition — a
*group* of objects defined by a range or a label — could not be selected without losing *why* those
objects belong together. Additive; the existing selection model is unchanged.

- **`Cohort`** (new frozen dataclass in `selection_service.py`): `members` + a human-readable
  `definition` ("area ∈ [12, 18) µm²", "genotype=WT") + a `kind` (`bin`/`group`/`aggregate`/`filter`).
  `SelectionState` gains a `cohort` field — **additive**: every existing consumer of
  `selected`/`hovered`/`pinned` is untouched.
- **`select_cohort(cohort, source)`** rides the existing service (same echo-suppression, generation
  counting, deferred lane). It sets `selected` to the members TOO, so a cohort-**unaware** view degrades
  gracefully — it reads `selected` and highlights every member — while a cohort-aware view reads `cohort`
  for the definition and count. **The image/labels overlay therefore highlights all members for free**
  (it already reads `selected`, k points). `clear` / `select_entity` / `toggle` clear the cohort; pins
  survive.
- **Comparative-figure groups emit cohorts** (`comparative_figures`): clicking a condition's unit-mean
  marker selects that whole condition as a cohort ("WT · 8 objects"), while an object point still selects
  one entity — nearest-wins. This unblocks the increment-3 box/violin case.
- **A cohort is a SELECTION, not a FILTER** — it never mutates the DataFrame or the analysed population
  (pinned in a test; the boundary noted in the docstrings). That is the deferred FilterStore's separate job.

New tests (`core`): `tests/test_cohort_selection.py` (round-trip, graceful degradation, echo-suppression,
clear-keeps-pins, selection≠filter) + a comparative-group cohort test. Full `pytest -m core` green (1124).

**Deferred** (clean follow-ons, the foundation now unlocks them): the histogram-bin emitter and the
aggregate-table-row emitter ("summarizes N objects" in the dock) — each is its own adapter; the emit
pattern is established by the comparative case, and rendering already works via the members-in-`selected`
degradation.

## [1.6.150] - 2026-07-19
### Changed — **Decompose `batch_step_registry.py`: 1663 → 432 lines (−74%), the fourth (and last-tracked) concentration point.**
The one god-file that had *grown* across the audit window, and the easiest to move safely — flat, no
classes, 26 replay handlers with the identical `(state, image_path, params, output_dir)` signature,
grouped by name prefix, and covered by a strong net (route-equivalence exercises batch replay end-to-end;
the OperationSpec composition guard reads `_STEP_MAP`). Behaviour-preserving move-don't-rewrite.

- The **26 `replay_*` handlers** moved into a new **`pycat.batch.steps`** package, split by prefix family
  (`io_steps`, `preprocessing_steps`, `segmentation_steps`, `brightfield_steps`, `invitro_steps`,
  `analysis_steps`), and the **10 shared helpers** into ONE `_common.py` (imported, never duplicated).
- **`_STEP_MAP` stays in `batch_step_registry.py`** and imports the handlers — the dispatch table must
  live in one place (it's what the composition guard reads). `register_all_steps` / `step_operations` /
  `_STEP_MAP` / `_STEP_OPERATIONS` are unchanged; no code imported an individual handler directly, so no
  re-exports were needed.
- **`replay_background_removal` deliberately stayed** in `batch_step_registry.py`: `test_batch_matches_the_recording`
  reads its SOURCE from that file (a white-box scale-logic check), so moving it would break a test the
  spec forbids editing — the target is met without it.
- New `tests/test_batch_step_map.py` (`core`): every `_STEP_MAP` entry resolves to a 4-arg callable —
  catches a bad move instantly. Line ratchet lowered 1663 → 432; the few moved broad handlers annotated
  `# broad-ok:`. Route-equivalence + the composition guard confirm replay is byte-for-byte unchanged.

Full `pytest -m core` green (1117). With this, all four tracked concentration points are decomposed —
`vpt_ui` (−54%), `ui_modules` (−41%), `file_io` (−42%), `batch_step_registry` (−74%).

## [1.6.149] - 2026-07-19
### Changed — **`ui_modules.py` decomposition, Phase 2: extract `MenuManager`. 5572 → 3268 lines (−41%).**
With Phase 1's menu-contract net in place, the safe move: `MenuManager` (2164 lines, ~33 methods, 39% of
the file) is lifted **verbatim** to a new **`src/pycat/ui/menu_manager.py`**, taking its two exclusive
helpers with it (`_FileDropFilter`, and the two session-restore method maps). `ui_modules.py`
**re-exports** it, so `from pycat.ui.ui_modules import MenuManager` (CentralManager, the smoke tests)
keeps working; `menu_manager.py` imports nothing from `ui_modules`, so there is no cycle.

- **The menu-contract snapshot matched, unchanged** — the Phase-1 net (`test_menu_contract.py`, now
  location-aware so it follows the class) confirms **not one of the 111 actions or 25 menus moved,
  renamed, or reordered**. That is the guarantee that makes a 2164-line move of an under-tested file
  safe, and it is exactly what the file was left un-split for the lack of.
- **`ui_modules.py`: 5572 → 3268 lines (−41%)**, under the ≤3600 target. Line ratchet lowered to 3268;
  `menu_manager.py` ratcheted at its 2344-line size.
- Behaviour-preserving: no rewrites, everything re-exported, full `pytest -m core` green (1115).
- **Not touched** (spec's explicit scope limit): `BaseUIClass`, `ToolboxFunctionsUI`, and the
  `AnalysisMethodsUI` family — moving those without behavioural tests is the blind refactor the ratchet
  warns about; a later spec can extend the same net to them.

**Deferred** (clean follow-up): `MenuManager`'s internal split into `ui/menus/{napari_menus,grid_view,
metadata_dialogs}.py` — that further thins `menu_manager.py` (not `ui_modules.py`, whose target is
already met) and is its own increment.

## [1.6.148] - 2026-07-19
### Added — **`ui_modules.py` decomposition, Phase 1: the menu-contract verification net (ships before any move).**
`ui_modules.py` is the file the codebase deliberately left un-split — its own ratchet warns *"a refactor
whose only verification is 'it still imports' ships bugs"* (a real one hid here once: a pixel-size gate
wrapped in `except: pass`, 1.5.509). So this spec builds the verification FIRST and ships it on its own;
**no code is moved yet.**

- **`tests/test_menu_contract.py` (`core`, pure AST)** — extracts the entire menu tree from
  `ui_modules.py` (every top-level menu + submenu title and, in order, the action labels under it: **25
  menus, 111 actions**) and asserts it against a committed reference (`tests/menu_contract_snapshot.json`,
  regenerate with `python tests/test_menu_contract.py --regenerate`). A blind refactor that silently
  drops, renames, reorders, or moves an action changes the contract and fails — the single highest-value
  guard, and exactly the regression hardest to catch by hand. Plus a non-triviality floor (so the guard
  can't pass vacuously) and a static check that `_setup_menu_bar` still assigns each guarded install's
  result attribute (the 1.5.509 bug class).
- **`tests/test_ui_smoke.py` (Qt-smoke, offscreen)** — the runtime companion: constructs the real
  `MenuManager` and asserts the guarded installs actually RAN (`_pycat_marker_action`, `palette_action`,
  a populated `_command_registry`, the layer-event backstops), that **every snapshot label became a real
  registered action** (cross-checking the static contract against the live menu), and that the guarded
  scene-switcher entry point is safe to invoke. *(These need an OpenGL-capable display, like the existing
  `test_ui_smoke` tests; they run in CI/with a display, not in this headless GL-less sandbox.)*

Full `pytest -m core` green (1115). Phase 2 (extracting `MenuManager` into its own module(s)) is a
separate version, gated on this net.

## [1.6.147] - 2026-07-19
### Changed — **Finish the `file_io.py` decomposition: the lazy wrapper + the exception conversion.**
Completes the two follow-ups deferred from 1.6.146.

- **`_ZarrTYX` → `file_io/lazy_sources.py`** (move 5) — the IMS lazy wrapper joins `_TiffPageStack` &
  friends in the Qt-free lazy-wrapper home. `lazy_sources` stays Qt-free (the subprocess headless test
  confirms it); `file_io` re-exports it. `file_io.py`: 1670 → **1633 lines (2805 → 1633 overall, −41.8%)**.
- **Exception conversion in the moved code** — the 45 broad `except Exception` handlers that moved into
  `naming.py` / `dialogs.py` / `stack_openers.py` are now annotated `# broad-ok:` with body-matched
  reasons (metadata probes → `None`, Qt/layer-inspection robustness, format-open log-and-continue). None
  were converted to raises — they are graceful-degradation swallows, not masked scientific failures, so
  a raise would change behaviour. The `file_io` broad-handler ratchet is lowered **284 → 239**.

Full `pytest -m core` green (1112 passed). This closes the `file_io.py` decomposition: dialogs, the
naming/pixel helpers, the format openers, and the lazy wrapper all in their proper homes; the file is
orchestration/wiring, not format-specific pixel logic; both the line ratchet (→1633) and the exception
ratchet (→239) are lowered so it cannot regrow.

## [1.6.146] - 2026-07-19
### Changed — **Decompose `file_io.py`: 2805 → 1670 lines (−40.5%), the second god-file after `vpt_ui`.**
Behaviour-preserving refactor — move code into its proper homes, no rewrites, no new features. Same
discipline as the `vpt_ui` decomposition (core green between each move, no test edited to make a move
pass, drop-guard move-records for everything relocated).

**Measured: `file_io.py` 2805 → 1670 lines (past the ≥39% target).** Four moves:
- `StackLoadCancelled` → `utils/errors.py` — kept a plain `Exception`, **deliberately not a
  `PyCATError`**: it is a user-cancel control-flow signal, not a failure, so `except PyCATError` must not
  swallow it. Re-exported.
- The two Qt dialogs (`LayerDataframeSelectionDialog`, `ChannelAssignmentDialog`) → `file_io/dialogs.py`.
- The pure pixel-size / naming helpers (`_lazy_contrast_limits`, `_tiff_pixel_size_um`,
  `_ome_pixel_size_um`, `_lazy_backing_label`) → a new **`file_io/naming.py`**, now headlessly testable
  (`tests/test_file_io_naming.py`).
- The three format-specific stack openers (`_open_stack_ims`, `_open_stack_generic`,
  `_open_czi_streaming`, ~600 lines) → **`file_io/stack_openers.py`** as a mixin. They were kept as a
  mixin, not standalone functions, because each writes `FileIOClass` state and calls sibling methods — a
  function form would take 10+ params or pass `self`, the "worse seam" the spec warns against.

Everything moved is **re-exported from `file_io.py`**, so every existing import path and call site still
works. The `file_io/file_io.py` line ratchet is lowered 2805 → 1670 so it cannot regrow. Full
`pytest -m core` green (1112 passed).

**Blocker recorded** (per the spec's "report blockers" rule): `derive_layer_name` and
`_clean_filename_token` could NOT move — two tests (`test_channel_modality`, `navigator/test_loader_fixes`)
AST-parse `file_io.py` *by path* and pull those functions out by name, so relocating them would fail
tests the decomposition is not allowed to edit. They stay put; the target was met without them.

**Deliberately deferred** (clean, non-target-critical follow-ups): moving the `_ZarrTYX` lazy wrapper to
`lazy_sources.py` (−63 lines more), and the broad-`except`→typed-error conversion in the moved code (the
handlers moved *within* the `file_io` package, so the package count is unchanged — a conversion sweep is
its own focused pass).

## [1.6.145] - 2026-07-19
### Changed — **Slim the distribution: stop shipping ~20 MB of docs/assets per release.**
PyPI enforces a total project-size quota and PyCAT was consuming it at ~25 MB per release, dominated by
documentation assets and unreferenced logos bundled into the artifacts. This is **build-config + asset
deletion only — no source-code change, no behaviour change.**

**Measured, before → after (`python -m build`):**
- **sdist: ~20.6 MB → 1.76 MB.** Dropped `docs/` (18 MB, mostly a 13 MB screenshot gallery) and
  `notebooks/` (816 KB) from the sdist `include` — they live in the git repo + the published docs site,
  and a `pip install` does not need them.
- **wheel: ~2.7 MB → 1.83 MB.** Deleted four unreferenced logo PNGs from `src/pycat/icons/`
  (`pycat_logo_1024.png` 572 KB, `pycat_logo-2.png`, `pycat_logo.png`, `pycat_logo_256.png` — ~880 KB
  total, grep-verified referenced nowhere in code). The two the app actually uses stay
  (`pycat_mark.png`, `pycat_logo_512.png`; icons now total 206 KB).

- **`.DS_Store`** — untracked the three tracked macOS files and added `**/.DS_Store` to the **wheel**
  exclusions (the sdist already excluded them, the wheel path did not).
- **`.xlsx` kept, deliberately.** `src/pycat/navigator/data/*.xlsx` (question tree, module contracts, tag
  hierarchy) **are read at runtime** by `navigator/loader.py` (`openpyxl.load_workbook` →
  `load_question_tree`/`load_raw_modules`/…), so they must ship — confirmed by grep, left in place.
- **Nothing runtime broke:** the built wheel still ships all required data — the 3 `.xlsx`,
  `navigator/data/operation_catalog.json`, and `utils/layer_bindings.json` (the dropdown-binding table).
  `run-pycat`'s icons (`pycat_mark`, `pycat_logo_512`) are intact.

`tests/test_distribution_size.py` (new, `core`) is the ratchet: it fails if `docs`/`notebooks` return to
the sdist, if an unreferenced or oversized icon ships (icons < 250 KB, each referenced or allow-listed),
if a `.DS_Store` reappears under `src/`, or if the wheel drops its `.DS_Store` exclusion. Full
`pytest -m core` green.

**Note:** this stops the bleeding but does **not** reclaim quota already consumed by past releases —
deleting old releases is a separate, manual, irreversible action on the PyPI web UI.

## [1.6.144] - 2026-07-19
### Added — **Comparative phenotyping increment 3: unblock brushing + the UI entry point.**
The comparative-figures library, superplots, and anti-pseudoreplication stats already shipped; this
completes the spec's Part D (brushing) and Step 2 (a UI entry point), which were blocked on a missing
per-object entity id.

- **The unblock — a resolvable `entity_id` in the consolidated table.** Object tables are already stamped
  with `_pycat_entity_id` by `stamp_entity_ids`, but `melt_object_measurements` dropped it. It is now
  carried through into a new `entity_id` column (`consolidated_table._CORE_COLS`), so a comparative-figure
  object row knows the global id the `SelectionService` already speaks — no fabricated id, no second
  keying scheme. Blank when the source table was never stamped.
- **Part D — single-entity brushing** (`comparative_figures._attach_object_brushing`, wired into
  `condition_comparison(..., selection_service=…)`): clicking an object point selects that entity through
  the EXISTING contract, self-highlighting on emit (the service suppresses a view's own receive) and
  ringing the matching point when a selection arrives from another view. Qt-free and headless-testable
  (matplotlib clicks don't fire under Agg, so the wired handlers are exposed on `fig._pycat_brushing`).
  **Cohort selection** (clicking a unit/condition marker to select the cohort it summarizes) is left as
  the noted-blocked seam — it needs the typed/cohort-target `SelectionState` still deferred on the
  interaction-layer roadmap. No second selection path was built, per the spec.
- **Step 2 — the UI entry point** (`ui/comparative_figures_ui.py`, menu: *Analysis Methods → Comparative
  Figures (batch consolidated table)*): pick a `consolidated_long.csv`, choose measurement / condition /
  biological unit / plot kind / optional test, and render the replicate-honest superplot with brushing
  wired to the shared `SelectionService`, alongside the inspectable summary frame.

New tests (`core`): `tests/test_comparative_brushing.py` (entity-id carry-through, object-click emits the
entity, a selection rings the point, no brushing without a service, the UI condition-field helper).
Two consolidated-table schema assertions updated for the new column. Full `pytest -m core` green (1108).

### Notes
- **Needs your in-app verification** (viewer-coupled): after a batch that wrote `consolidated_long.csv`,
  open *Analysis Methods → Comparative Figures*, render a superplot, and confirm clicking an object point
  brushes it in any open object layer (and vice-versa).

## [1.6.143] - 2026-07-19
### Changed — **Lightweight operation catalog: discovery no longer imports science modules; runnability reaches the Run buttons.**
Two linked findings. **Finding 1 — discovery is decoupled from implementation imports.**
`iter_operation_specs(live=False)` (the new default) builds the full `OperationSpec` catalog by reading
the generated `operation_catalog.json` — **without importing a single science module**. A missing
optional/specialist dependency (`pywavelets`, a GPU library) no longer makes a third of the operation
vocabulary undiscoverable; the operation is *listed*, and only fails — precisely, for that one op — when
it is actually run.

- `live=True` keeps the import-and-introspect path; the catalog GENERATOR (`build_catalog_document`) and
  the regeneration/drift guard now pin `live=True` explicitly (a generator that read its own output would
  be circular). The drift guard is what keeps the JSON faithful to the live decorators, so reading the
  artefact is safe.
- `OperationSpec` gains `module` + `function` (the executor coordinates, populated in both paths).
  `resolve_operation(spec)` imports the implementation **at call time** and raises a precise
  `OptionalDependencyError` naming the missing dependency for that operation. `module_importable(spec)`
  and `operation_availability(spec, available, check_module=…)` compose the requirement gate with an
  optional import probe.

**Finding 2 — consume the existing `OperationSpec` fields in the live UI.**
- **Run-button gating.** New `ui/operation_gating.py`: `session_facts(central_manager, viewer)` derives
  the available facts (`z_stack`/`time_axis`/`pixel_size`/`two_channels`/`gpu`) from the same predicates
  the tools already use (`has_time_axis`, `has_real_pixel_size`, `gpu_available`, the layer `axis_order`
  tag, `n_channels`); `gate_run_button(button, requirements, …)` disables the button with the stated
  reason (*"needs a 3D z-stack"*) and re-checks as layers change. **Fail-open** — any gating error leaves
  the button enabled. Wired into the five requirement-declaring ops that have a Run button: the three
  z-stack 3D tools (Remove Background / Segment Cells / Segment Condensates), VPT Link Trajectories, and
  the Temporal Enhancement competition. *(The other three requirement-declaring ops — `dog_3d`,
  `gabor_3d`, `gaussian_3d` — are internal tri-planar pipeline helpers with no UI button of their own, so
  they are covered only by the headless `operation_availability` API, not a widget.)*
- **Batch audit.** The recorded-steps dialog now shows each step's declared operation composition
  (`step_operations`), so replay is auditable in the UI, not only in tests.
- **Layer-input filtering** (spec's item 2) is already served by the existing tag-based `binding`
  mechanism in `create_layer_dropdown`, which resolves layers by `role`/`target` exactly as `inputs`
  would — no change needed.
- Per the spec, **no new `requirements` values** were added; `module_importable` is surfaced through the
  availability API, not the per-op vocabulary.

New tests (`core`): `tests/navigator/test_lightweight_catalog.py` (import-free discovery, lazy resolver
with precise errors, availability) and `tests/test_operation_gating.py` (session facts, disable-with-
reason, fail-open). Full `pytest -m core` green (1102 passed).

### Notes
- **Needs your in-app verification** (viewer-coupled): open a 2D image and confirm the three "…(3D)"
  Run buttons in the Z-Stack tool are disabled with a "needs a 3D z-stack" tooltip, and enable when a
  z-stack is loaded; likewise VPT "Link Trajectories" and the Temporal Enhancement "Run competition"
  button on a single frame vs a time-series.

## [1.6.142] - 2026-07-19
### Added — **Focus selection: close the spatial debris layer (optional `mask=` on the focus-series scorers).**
Completes the "best frame must not be the sharpest DEBRIS" rubric. The **statistical** layer already
shipped (1.6.91): `math_utils.robust_focus_energy` trims the top ~1% of per-pixel magnitudes, so a
*small* out-of-plane speck cannot hijack the chosen frame. This adds the **spatial** layer for the two
focus-series scorers, for the case trimming cannot reach — a *large* out-of-plane structure.

- `bf_analyse_focus_series` (`brightfield_tools`) and `analyse_frame_quality` (`condensate_physics_tools`)
  gain an optional **`mask=`** — a single `(H, W)` boolean applied to every frame, or a `(T, H, W)`
  per-frame stack. When supplied, the focus metrics (Brenner/Tenengrad/normalised variance;
  Laplacian variance/entropy/gradient energy) are scored **inside the masked region only**.
- **Masked pixels are extracted, never zero-filled** — a zero-fill outside the mask would create a
  high-gradient artefact at the boundary that inflates every metric. Gradients/Laplacians are computed on
  the full real frame, then aggregated over masked pixels.
- **`mask=None` is byte-identical** to the previous whole-frame behaviour; a full-True mask reduces to
  exactly the whole-frame numbers (pinned in the test). Mean intensity stays whole-frame (it feeds
  bleaching detection, not focus).
- New shared helper `math_utils.resolve_frame_mask` (per-frame mask, raises on shape mismatch — a wrong
  mask is worse than whole-frame, so it fails loudly). `_frame_entropy` / `_frame_gradient_energy` gained
  a `mask=` too. The trim layer is kept **on top of** the mask — they are complementary, not alternatives.
- **No caller fabricates a mask.** The frame-QC UIs run on a raw stack with no biological mask in hand, so
  they pass `None`; the capability is there for a caller that genuinely has a region.

`tests/test_focus_debris.py` (new, `core`): a deliberately adversarial fixture (debris ≥8% of the frame,
above the 1% trim, sharper per-pixel than the condensate) proves `mask=None` picks the debris frame and
`mask=` picks the condensate — for both scorers; a clean stack picks the same frame either way; the trim
layer defeats small debris independently; a wrong-shaped mask raises. Full `pytest -m core` green.

## [1.6.141] - 2026-07-19
### Added — **VPT results are now one combined dock with full four-way brushing and a bucket pager.**
The Video Particle Tracking results used to surface as three disconnected top-level things — the 2×2
figure in pyplot windows, the per-track table in a standalone `QDialog`, and only the histogram as a real
dock — and only the MSD plot actually brushed the others. This replaces that with **one dockable widget**
(`toolbox/vpt/results_dock.py`, `_VptResultsDockMixin`): the 2×2 figure canvas on the left, the per-track
table on the right (a horizontal splitter), driven the default way; the `_plots_consolidated` checkbox
still opts out to the old pop-out windows + table dialog.

- **The combined dock is also the brushing fix.** The table→plot / image→plot highlights were silently
  no-op'ing against dead canvases because the old pyplot/`QDialog` surfaces were disposable and took their
  `_msd_line_registry` / `_track_table_registry` with them when they closed. Embedding the figure canvas
  and the table in one persistent dock keeps the highlight targets alive.
- **Image → everywhere now works regardless of the active napari layer.** The bead-click picker was a
  *layer* callback, so napari only delivered it when "Bead Picker" was the active layer — the usual case
  (image/Tracks layer selected) left image→plot/table dead. Added a **viewer-level** pick
  (`_install_viewer_bead_pick`) that fires whatever layer is active, resolving the **nearest** bead on the
  current frame within a pixel radius (`_nearest_bead_tid`) — napari's exact-hit `get_value` never lands
  on one of hundreds of thousands of tiny dense beads, which is why bead picking looked dead.
- **Centered trajectories are a real fourth SelectionView** (`vpt.centered`). `_draw_centered_tracks` now
  keeps a per-track line/coords map with promote/demote and a click hit-tester, so clicking a centered
  path selects that track everywhere, and a selection from any other view emphasises its centered path
  (promoting one off the current page on demand). It was inert before.
- **Bucket pager** — the MSD spaghetti and centered panels cap how many tracks they draw (the spread stops
  changing past ~100). A `◀ Prev / Next ▶` pager with a live bucket-size spinbox pages through literal
  slices of tracks (no representative sampling), so **every track is on a plot on some page**; page 0 is
  the representative ensemble. A selection survives page turns (re-applied on the new page's artists).

### Notes
- **Needs your in-app verification** (viewer-coupled — headless-green only): open a VPT result and confirm
  (1) the combined dock lays out figure-left / table-right; (2) clicking a bead reveals it AND highlights
  the table row + MSD curve + centered path, from any active layer; (3) clicking a table row, an MSD
  curve, or a centered trajectory brushes all the others; (4) the pager steps through buckets and the
  highlight persists across pages.
- `analysis_plots` gains `only_tids` on `_draw_msd_into` / `_draw_centered_tracks` (draw an exact slice,
  no sampling) and a registry/hit-test on the centered panel; the pop-out path is unchanged.
- Full `pytest -m core` green; ratchets (complexity, drop-guard, exception budget, silent-fallbacks) green.

## [1.6.140] - 2026-07-19
### Changed — **Reliability adoption: the two zero-bar widgets now run off the Qt thread through the operation runner.**
Completes the reliability spec's Part 2 — the proof adoption. The two analyses whose slow work is the
computation (Cascade RF segmentation in `contrast_cascade_ui`, per-half-cycle rip fitting in
`fd_curve_ui`) now run through `OperationRunner` on a worker thread behind `run_with_progress`'s modal
progress dialog, and create their layer / show their tables back on the main thread via `on_result`. A
failure is transported to a `napari_show_warning` via `on_error`. **Responsive, not merely visible** —
no "Not Responding" during a multi-second segment or fit.

- This **supersedes the on-thread inline `QProgressBar`** those widgets got in progress-part-2 (1.6.132):
  the modal dialog (driven by the same tool `progress_callback`, forwarded by the runner) is now the
  progress indicator, so the inline bars were removed. `test_progress_analysis_half.py` is updated to
  ratchet the stronger guarantee — each widget must route its slow analysis through `OperationRunner`
  (a regression to a direct on-thread call fails), while the tool entry points still accept
  `progress_callback` (unchanged).
### Notes
- Only these **two** widgets were converted (the spec's "prove the runner, then migrate incrementally").
- **Needs an in-app glance** (viewer-coupled): confirm the modal dialog appears and the UI stays
  responsive during a real Cascade-RF segmentation / rip fit, and the result lands correctly.
- Full `pytest -m core` green.
- Files: `src/pycat/toolbox/contrast_cascade_ui.py`, `src/pycat/toolbox/fd_curve_ui.py`,
  `tests/test_progress_analysis_half.py`.

## [1.6.139] - 2026-07-19
### Added — **Reliability: typed failures + an exception ratchet + one operation runner.**
The two reliability findings the audit flagged as unchanged across revisions — broad exception handling
and per-widget background execution — addressed as **ratchets + one shared mechanism**, not a sweep.

**Part 1 — typed failures + the exception ratchet.**
- **`utils/errors.py`** — a small `PyCATError` family (`UnsupportedFormatError`,
  `MetadataUnavailableError`, `InvalidCalibrationError`, `ScientificAssumptionError`,
  `OptionalDependencyError`, `LayerResolutionError`), exactly the failures the code already
  distinguishes. `ScientificAssumptionError`/`UnsupportedFormatError` also subclass `ValueError`, so
  existing `except ValueError` callers keep working while new code can catch the family.
- **`tests/test_exception_budget.py`** — a per-package ratchet on un-annotated `except Exception`, at
  today's counts (1222 total across the tree), that only decreases. A deliberate handler annotates itself
  `# broad-ok: <reason>` (mandatory reason) and drops out of the count. This stops the measured growth at
  zero cost — the complexity budget's principle, applied to swallows.
- **First scientific conversions:** the `calibration.py` ΔG/curve gates (non-positive concentration,
  Celsius-as-Kelvin, too-few-points, bad schema) now raise typed errors that **name the assumption**,
  instead of a bare `ValueError`. `test_calibration.py` passes unmodified (the typed errors are
  `ValueError` subclasses).

**Part 2 — one operation runner.**
- **`utils/operation_runner.py`** on top of the existing `qt_worker` (no second threading mechanism):
  `execute(fn, progress=, on_result=, on_error=, cancellation=, generation=)` standardizes worker
  policy, **main-thread marshalling** of the result, **stale-result suppression** (a generation counter,
  so a slow result cannot overwrite a newer request), cooperative **cancellation** at progress
  boundaries, and **typed error transport** to `on_error`. Fully headless-tested (`run_with_progress`
  runs synchronously with no event loop): result delivered on the caller's thread, progress forwarded
  verbatim, a superseded result discarded, cancel stops the work, a typed error reaches `on_error`.
### Notes
- **The 2-widget adoption is NOT in this release** (deliberately). Routing the two zero-bar widgets'
  slow analyses through the runner moves them *off* the Qt thread behind `run_with_progress`'s modal
  dialog — which supersedes the *on-thread inline bar* progress-part-2 (1.6.132) added, and so conflicts
  with that spec's inline-bar ratchet. Resolving that (inline bar vs modal dialog) is a UX decision and
  the off-thread behaviour needs an in-app check, so the adoption is held for that pass. The runner is
  proven by its tests; adopting it is the demonstration.
- Full `pytest -m core` green.
- Files: `src/pycat/utils/errors.py`, `src/pycat/utils/operation_runner.py`,
  `src/pycat/utils/calibration.py`, `tests/test_exception_budget.py`, `tests/test_operation_runner.py`.

## [1.6.138] - 2026-07-19
### Changed — **vpt_ui decomposition, step 3: three more adapter modules — `vpt_ui.py` 1778 → 1139 lines (2458 → 1139 overall, −54%).**
The rest of the decomposition. Three more responsibility groups moved out of `vpt_ui.py` into the `vpt/`
package as behaviour-preserving mixins, taking the file from the panels-step 1778 to **1139 lines — a
54% reduction from the original 2458**, more than double the ≥25% target. `vpt_ui.py` is now close to
construction, wiring, and composition only.

- **`vpt/napari_adapter.py`** (`_VptNapariMixin`) — the napari-facing layer/overlay/reveal methods:
  Tracks/Points layer build, the picked-bead ring, reveal + navigate + camera, session-view restore.
- **`vpt/table_adapter.py`** (`_VptTableMixin`) — the track-table methods: per-track table build,
  row↔entity, table selection callback.
- **`vpt/msd_adapter.py`** (`_VptMsdMixin`) — the MSD-plot methods: per-track highlight/hit-testing,
  track-length histogram, plot selection callback.
### Notes
- **Behaviour-preserving moves, not rewrites** — every method body is byte-for-byte unchanged; only the
  module changed, and `VideoParticleTrackingUI` composes the four mixins. Verified up front that each
  moved group uses only `self` + imports (zero module-level `vpt_ui` names), and each new module copies
  `vpt_ui`'s import block verbatim, so no name can be missing.
- **Every pre-existing VPT/selection/brushing test passes unmodified.** Only the two bookkeeping ratchets
  were updated as designed: the drop guard's `_DELIBERATE` records the moved functions (it keys by
  filename, and nested pick/row/close handlers moved with their parents), and the `vpt_ui.py` ceiling is
  lowered to **1139**. Full `pytest -m core` green.
- **Deliberately NOT rewritten here:** formalising the msd/table adapters as standalone `SelectionView`s
  (`view_id`/`apply_selection`/`close`) covered by the shared contract suite is the interaction-layer §8
  *rewrite*, out of scope for a move. The MSD brushing stays its registry-based model. **In-app
  verification of the live VPT widget is still needed** (no headless test exercises the constructed
  widget — the VPT tests instantiate a bare subclass).
- Files: `src/pycat/toolbox/vpt_ui.py`, `src/pycat/toolbox/vpt/napari_adapter.py`,
  `src/pycat/toolbox/vpt/table_adapter.py`, `src/pycat/toolbox/vpt/msd_adapter.py`,
  `tests/test_nothing_was_dropped.py`, `tests/test_complexity_budget.py`.

## [1.6.137] - 2026-07-19
### Changed — **vpt_ui decomposition, step 2: the panel builders MOVE out — `vpt_ui.py` 2458 → 1778 lines (−28%).**
The measurable win the external audit asked for: it charged that the new abstractions were being added
*beside* the concentration points rather than absorbing them, and set as its success metric that one
concentration point becomes **materially smaller**. This does it for `vpt_ui.py`, the audit's named
first target — a **28% reduction, past the ≥25% goal**.

- The five pure-layout panel builders (`_add_bead_detection`, `_add_tracking`, `_add_microrheology`,
  `_add_host_segmentation`, `_build_per_track_metrics`) moved into a new **`toolbox/vpt/panels.py`** as
  the `_VptPanelsMixin`. `VideoParticleTrackingUI` now *composes* them (inherits the mixin) instead of
  implementing them — the file is closer to construction-and-wiring only.
- **Behaviour-preserving move, not a rewrite:** the method bodies are byte-for-byte unchanged; only the
  module they live in changed. `setup_ui` (the top-level construction/composition **and** the
  pixel-size-gate install) deliberately **stays** in `vpt_ui.py`, per the spec's rule that the file
  retains composition — which also keeps the pixel-size-gate contract satisfied in place.
### Notes
- **Every pre-existing VPT/selection/brushing test passes unmodified** — the move changed no behaviour.
  Only the two *bookkeeping* ratchets were updated as designed: the drop guard's `_DELIBERATE` records
  the five moves (it keys by filename, so a move reads as a vanish), and the per-file ceiling for
  `vpt_ui.py` is **lowered 2458 → 1778** (the ratchet moving down). Full `pytest -m core` green.
- **The remaining three adapter modules (napari / table / msd) are NOT in this release.** The line
  target is already met by this one extraction, and the first extraction surfaced a real, subtle failure
  (a length-reporting method and its pixel-size gate split across the two files) that was invisible until
  the full suite ran — proof that these need verification between steps, and that the *live VPT widget*
  (not exercised by any headless test — the VPT tests instantiate a bare subclass) needs an in-app check
  before more surgery. The napari/table/msd extractions, and formalising the msd/table adapters as
  `SelectionView`s (the deferred interaction-layer §8 rewrite), are the follow-on.
- Files: `src/pycat/toolbox/vpt_ui.py`, `src/pycat/toolbox/vpt/__init__.py`,
  `src/pycat/toolbox/vpt/panels.py`, `tests/test_nothing_was_dropped.py`, `tests/test_complexity_budget.py`.

## [1.6.136] - 2026-07-19
### Added — **Per-file line ratchets on the four concentration points (vpt_ui decomposition, step 1).**
Step 1 of the `vpt_ui.py` decomposition spec, and the part the spec itself flags as the
highest-value/lowest-cost: an external audit measured that while the new abstractions
(`SelectionService`, `OperationSpec`, the plot backends, the scene stack) were added, the god-files grew
or held **beside** them — `ui_modules.py` +18, `file_io.py` +18, `batch_step_registry.py` +50,
`vpt_ui.py` flat. The architecture is real but so far additive. `test_complexity_budget.py` now carries a
**whole-file line ceiling per concentration point**, set at today's value, that only ever moves DOWN — so
a new abstraction added beside a god-file instead of absorbing code fails the build. This stops the
measured drift immediately, at zero refactoring cost.
### Notes
- Ceilings (today's values): `vpt_ui.py` 2458, `ui_modules.py` 5573, `file_io.py` 2805,
  `batch_step_registry.py` 1663. Lower one after a real extraction; never raise one.
- **The extractions (spec steps 2–5) are NOT in this release.** They are behaviour-preserving *moves* of
  responsibility out of `vpt_ui.py` into a `vpt/` adapter package — but its `_build_*` UI-construction
  methods are not exercised by any headless test (the VPT tests instantiate a bare subclass that skips
  `__init__`), so a moved-but-broken panel would pass import + the AST/method tests yet fail only in the
  live widget. That makes each extraction viewer-coupled work needing in-app verification, done one at a
  time — flagged rather than shipped blind.
- Test-only, no production change. Full `pytest -m core` green.
- Files: `tests/test_complexity_budget.py`.

## [1.6.135] - 2026-07-19
### Added — **Comparative phenotyping inc 3: cross-condition figures on the consolidated table, replicate-honest by construction.**
The visible payoff of the arc: figures that compare conditions, built on increment 2's consolidated long
table. Existing `comparative_stats.py` already carried the honest replicate-level testing; this completes
`comparative_figures.py` to the increment-3 contract — three figure types, each returning
`(Figure, summary_df)` so the numbers behind a figure are always inspectable.

- **`condition_comparison`** — the superplot: objects (light) + per-unit means (dark) + a box/violin per
  condition. **Descriptive by default** — a p-value appears only when `test=True`, and then it comes from
  `compare_conditions` (replicate-level, named, refusing loudly below the minimum).
- **`dose_response`** — measurement vs a numeric condition field; unit means ± SEM (over units, not
  objects) at each dose.
- **`measurement_matrix`** — the new figure type: a small-multiples grid, one condition-comparison panel
  per measurement, for scanning several at once.
- **`aggregate_to_unit`** — the anti-pseudoreplication step, generalised to multi-field conditions/units;
  the biological unit is **declared** (`unit_cols`), defaulting to the image when no replicate is named,
  never inferred from data shape.
- Every summary frame reports **n at each level** (`n_objects`, `n_units`) and the unit-level SEM —
  deliberately NOT the object-level SEM, which is the pseudoreplicated lie the module refuses.
### Notes
- **Pseudoreplication, refused by construction — and proven, not commented:** the headline test builds
  450 objects from 3 replicates and asserts the reported n is 3 and the error bar is *materially wider*
  than the naive object-level SEM (measured ~8×). Other `core` tests: the condition effect is recovered;
  a requested test names its unit; a condition with too few units gets a stated refusal, not a p-value.
- **Part D (brushing) is blocked on prerequisites, deliberately not faked:** routing a click to the
  `SelectionService` needs (1) the consolidated table to carry a resolvable entity id per object row (an
  increment-2 extension) and (2) the deferred cohort/typed-target `SelectionState` (interaction-layer
  §3/§4). Per the spec, selection is left unwired with the seam documented rather than built as a second
  path. The figures already show the single-vs-cohort distinction visually. A **UI entry point** (read
  the consolidated CSV, render) is the remaining viewer-coupled follow-on.
- With Parts A–C done, **comparative-phenotyping increment 3's library is complete**; heatmap/PCA/
  clustering profiling is deliberately declined per the spec. Full `pytest -m core` green.
- Files: `src/pycat/utils/comparative_figures.py`, `tests/test_comparative_figures_inc3.py`.

## [1.6.134] - 2026-07-19
### Added — **Comparative phenotyping inc 2: the batch emits one tidy `consolidated_long.csv` — the keystone comparative output.**
The `ConsolidatedLongWriter` (the long-format assembler that melts each image's per-object tables and
attaches condition + provenance columns) shipped with unit tests, but **nothing called it from the batch
loop** — so the arc's keystone deliverable was unbuilt: a study across N images was still N per-image
folders a scientist joins by hand. This wires it in.

- **`batch_processor.BatchWorker.run`** now streams a top-level `consolidated_long.csv`: after each
  image is processed it reads that image's already-written per-image CSVs (`<stem>_cell_df.csv`,
  `<stem>_puncta_df.csv`), melts them to long rows, and appends them with the image's condition
  (WT/dose/replicate…, from increment 1's resolver) and provenance (pycat version) on every row. Holds
  no other image in memory (streaming append), with a schema fixed up front from the condition-field
  vocabulary so a column can never drift mid-batch.
- **Additive:** the consolidated table sits *alongside* the existing per-image folders and removes
  nothing; an image that produced no object tables contributes no rows; conditions are blank (never
  guessed) when no metadata source is configured.
- New Qt-free helpers: `consolidated_table.records_from_output_dir` (read an image's per-image CSVs
  back as `(object_type, df)` records — the streaming-from-disk source) and
  `SampleMetadataResolver.condition_field_names` (the field vocabulary, known before any pixels are
  read, that fixes the CSV schema).
### Notes
- With this, **comparative-phenotyping increment 2 is complete** — the long/tidy table the grouped
  stats and faceting of increments 3–4 join on. (Increments 3–4's *library* code exists; their UI
  surfaces remain, tracked as follow-on.)
- Tests (`core`, headless): the per-image reader reads/skips correctly; the field vocabulary unions
  sheet + pattern; two images stream into one long table with conditions and provenance per row; an
  empty image adds no rows; the batch loop's wiring is AST-verified (`batch_processor` is Qt-bound).
  Full `pytest -m core` green.
- Files: `src/pycat/batch_processor.py`, `src/pycat/utils/consolidated_table.py`,
  `src/pycat/utils/sample_metadata.py`, `tests/test_consolidated_batch_wiring.py`.

## [1.6.133] - 2026-07-19
### Added — **Comparative phenotyping inc 1, Parts B & C: wire the condition/metadata resolver into batch + session persistence.**
Increment 1's Part A — the `SampleMetadataResolver` (attach a condition like "WT replicate 2 at 10 µM"
to an image from a sample sheet / filename pattern / in-app tag, behind one API) — shipped earlier, but
its two integration points were never wired: the resolver was only consumed by the consolidated table
(increment 2), never by the batch loop or the session manifest as the spec specified. This closes that.

- **Part B — batch** (`batch_processor.BatchWorker.run`): builds one resolver per run from the config
  (`sample_sheet_path` / `sample_filename_pattern`, the keys the batch UI populates) and writes a
  `<stem>_sample_metadata.json` beside each image's results recording the resolved `fields` + which
  `source` supplied each. **Strictly additive:** with no source configured the resolver is `None` and
  nothing is written — a metadata-less batch produces exactly the files it did before. An unmatched
  sheet row warns once (a likely filename typo), never crashes.
- **Part C — session** (`writers._write_session_manifest` + `session_loader`): an in-app condition tag
  placed in the data repository is carried into the manifest on Save & Clear and restored into the
  repository on Load Session, so a tagged image comes back tagged. **Back-compat both ways:** an
  untagged session's manifest is byte-identical to before, and a manifest written before the field loads
  as "no tag".
- New Qt-free helpers in `utils/sample_metadata.py`: `write_image_sample_metadata` (the per-image
  writer, a no-op when the resolver is `None`) and `resolver_from_config`.
### Notes
- **The condition/metadata model is now fully realized** across all three attach paths and both
  integration points — the prerequisite the consolidated table (inc 2) and comparative figures (inc 3)
  join on. This resolves the one PARTIAL item from the spec audit (Parts B/C were unwired).
- Tests (`core`, headless): the per-image writer records/omits correctly and is a no-op without a
  source; the batch loop's wiring is AST-verified (batch_processor is Qt-bound); an in-app tag
  round-trips through Save → Load; an untagged session stays back-compatible. No behaviour change when
  no metadata source is configured. Full `pytest -m core` green.
- Files: `src/pycat/utils/sample_metadata.py`, `src/pycat/batch_processor.py`,
  `src/pycat/file_io/writers.py`, `src/pycat/file_io/session_loader.py`,
  `tests/test_sample_metadata_wiring.py`.

## [1.6.132] - 2026-07-19
### Added — **Progress, part 2: the ANALYSIS half — tool-level `progress_callback` for the two zero-bar widgets.**
The materialization half of the progress work shipped in 1.6.81/82/107 (decoding a lazy stack shows a
bar and runs off-thread). This is the different problem the roadmap parked: two widgets whose slow work
is **the analysis itself**, which had **nothing on screen** during a multi-second run — the verified gap
(`contrast_cascade_ui` and `fd_curve_ui` each had zero `QProgressBar` and zero tool-side progress).
Because the slowness is the computation, a bar alone would have nothing to drive it, so the tool
functions report progress first.

- **Part A — tool-side `progress_callback`** (the `materialize_stack` signature, so `PhasedProgress`
  composes; `None` is a complete no-op):
  - `contrast_cascade_tools.cascade_rf_segment` reports at its three **stage** boundaries (build
    features → train → predict). Each stage is one opaque call, so stage-level progress is honest — a
    per-pixel bar would fake a granularity the work doesn't have.
  - `fd_curve_tools.detect_all_rips` reports **per half-cycle** — the real countable loop, where each
    iteration runs a WLC fit and is the slow part.
- **Part B — the two widgets** now build a real **`QProgressBar`** (never a `QLabel`: `setValue`
  repaints synchronously and moves on a busy thread, `setText` does not) and drive it from the tool
  callback via `PhasedProgress`.
- **Part E — a sibling ratchet** (`tests/test_progress_analysis_half.py`, AST-based so it runs headless):
  the slow tool entry points must accept `progress_callback`, and the two widgets must construct a
  `QProgressBar` driven from that callback. A future zero-feedback slow widget fails here.
### Notes
- **Part D sweep — clean.** Every existing progress indicator in the app is already a `QProgressBar`,
  not a `QLabel` — the many `setRange(0, 0)` sites (brightfield / condensate-physics / in-vitro runners)
  are *honestly indeterminate* bars for single-opaque-call steps, which the spec explicitly permits. So
  the "a status label is not a progress reporter" hazard does not exist in the tree; no conversion was
  needed. **Part C** (converting per-cell/per-object *core* runners from indeterminate to determinate)
  is identified follow-on: each needs its own tool-side `progress_callback`, and the current
  indeterminate bars are correct for the single-call steps.
- **Same honest limit as part 1:** this makes the wait **visible, not shorter**. Off-thread execution of
  the analysis is a separate, larger change.
- **In-app verification (viewer-coupled):** the *moving-bar* behaviour of the two widgets is confirmed
  structurally (tool callbacks fire correctly, verified headlessly; bars constructed + wired, verified by
  AST), but the visible bar during a real Cascade-RF segmentation / rip-fit wants an in-app glance.
- Files: `src/pycat/toolbox/contrast_cascade_tools.py`, `contrast_cascade_ui.py`, `fd_curve_tools.py`,
  `fd_curve_ui.py`, `tests/test_progress_analysis_half.py`.

## [1.6.131] - 2026-07-19
### Added — **Filtering-defaults sensitivity harness, increment 3: the partition-coefficient camera-offset default (a LIVE inverter, pinned).**
Increment 1 built the harness and proved it on two fixed inverters; increment 2 added the segmentation
SNR (offset) and ring-geometry (scale) cases and correctly excluded `bleach_r2_min`. Increment 3 is the
next prioritisation call. The survey of the remaining ~37 candidate defaults found **no new *fixed*
inverter** — but it found a **live** one worth pinning, and several non-cases worth recording so the next
pass doesn't re-litigate them.

- **`partition.client_enrichment.background` (offset sensitivity) — ADDED.** The partition coefficient
  K = (dense − bg)/(dilute − bg) is exact at any camera pedestal *provided the offset is supplied*. The
  **default `background=0.0`** asserts there is none, so a real pedestal sits in both terms and drags K
  toward 1 — measured on a true K of 30: **30 / 15.5 / 5.83 / 2.38** at pedestals 0/100/500/2000, a 12×
  error on a flagship condensate metric. The function *warns* but still returns the wrong number, so
  this is the **first case whose negative control is the current default**, not a removed form: the
  positive control supplies the offset and recovers K at every pedestal; the negative drives the same
  real function with the default 0.0 and the harness catches the inversion (both controls exercise the
  real `client_enrichment` — no reimplementation).
### Notes
- **Non-cases recorded** (in the registry comment + `DEV_NOTES.md`) so they aren't re-evaluated:
  `segmentation.min_spot_radius=2` and `client_enrichment_per_condensate shell_px=5` are **live scale
  risks** but the same scale shape already covered by `local_ring_geometry` (reported as findings, a
  production fix is separate work); `max_area_fraction=0.25` is **safe by construction** (a fraction of
  cell area → scale-invariant); `kurtosis_threshold=-3.0` is **inert** (excess kurtosis has a −2 floor,
  so `< −3` never fires); `estimate_object_size_px_brightfield` is **dead/unwired** (excluded like
  `defocus_r2_max`).
- Test-only, no production behaviour changed. Fixture reused: `fixtures_synthetic.partition_scene`
  (known K). Full `pytest -m core` green.
- Files: `tests/filter_sensitivity.py`, `tests/test_filter_sensitivity.py`, `docs/audits/DEV_NOTES.md`.

## [1.6.130] - 2026-07-19
### Added — **Multi-scene switcher: load one position at a time, lazily, and switch in place.**
A multi-position acquisition (CZI/IMS/OME-TIFF) used to load **every selected scene into memory at
once** — the load-everything profile the streaming work removed everywhere else, with no way to change
position without reopening. Now a multi-position file loads **exactly one scene, lazily**, and a dock
switches position in place.

- **`_SceneStack`** (`file_io/lazy_sources.py`) — the lazy (T, Y, X) wrapper for one scene. Reads one
  plane at a time from its **pinned** scene, refuses `__array__`, and — the headline hazard — re-pins
  the scene on **every** read, so a shared stateful reader can never serve a frame from another
  position. Switching builds a fresh wrapper, so no plane cache is shared across scenes: a stale
  previous-position frame cannot exist by construction.
- **`file_io/scenes.py`** (new, Qt-free) — `build_scene_stack`, `tag_scene_layer`/`scene_of` (scene
  identity as a queryable tag, joinable to the comparative-phenotyping sample metadata — a position is
  often a condition), `list_scenes`/`scene_index`.
- **Routing** (`file_io._open_stack_generic`) — a multi-position file now loads **one** scene (the
  first chosen, default scene 0) and tags each lazy layer with its position. **Single-scene files are
  untouched.** The several-scenes-overlaid memory footgun is gone rather than kept beside the default.
- **Per-scene calibration** (`data/data_modules.update_metadata`) — reads the **currently selected**
  scene's pixel size, not a fixed scene 0, so a switch cannot silently mis-scale (a position can
  legitimately differ).
- **The switcher dock** (`ui/scene_switcher.py`, new) — a position dropdown (File menu → "Switch
  Position / Scene"). Switching rebinds every scene layer to a fresh `_SceneStack` for the new position,
  warms the first (slow) frame **off the Qt thread** (`run_with_progress`, no "Not Responding"),
  re-reads per-scene calibration, re-tags the layers, and **stamps derived layers with the position
  they were computed on** so a mask from position 1 cannot masquerade as belonging to position 2.
### Notes
- Headless-tested (Qt-free foundation): `_SceneStack` contract + one-plane read + scene-provenance
  (`test_scene_stack.py`), the scene helpers + per-scene metadata (`test_scenes.py`), and the switcher's
  `switch_to` rebind/re-tag/stale-derived logic (`test_scene_switcher.py`, `run_with_progress` runs
  synchronously with no event loop). The AST eager-read guard auto-covers the new wrapper. Full
  `pytest -m core` green.
- **Needs in-app verification** (viewer-coupled, cannot be checked headlessly): opening a real
  multi-position file loads one position; the File-menu switcher changes it in place without a freeze or
  a stale frame. Held for that verification before release.
- Files: `src/pycat/file_io/lazy_sources.py`, `src/pycat/file_io/scenes.py`,
  `src/pycat/file_io/file_io.py`, `src/pycat/data/data_modules.py`, `src/pycat/ui/scene_switcher.py`,
  `src/pycat/ui/ui_modules.py`, `tests/test_scene_stack.py`, `tests/test_scenes.py`,
  `tests/test_scene_switcher.py`.

## [1.6.129] - 2026-07-19
### Added — **OperationSpec increment 5: `requirements` — runnability gating with a stated reason. (The OperationSpec arc is complete.)**
The last field of the OperationSpec roadmap. Increment 2's `inputs` said which *layers* an operation
consumes; increment 5 adds `requirements` — the *data/environment* preconditions it needs to be runnable
at all: a 3D z-stack, a time axis, a calibrated pixel size, two channels, a GPU. Declared on
`@tags_layer` from a controlled vocabulary, **with the validation that makes it real** — so a consumer
can gate an operation **before** the click and **say why** ("needs a 3D z-stack"), instead of letting it
fail at run time.

- **`tag_registry.REQUIREMENTS`** — a controlled name→reason map (the single source of the requirement
  vocabulary and its human-readable phrasing). `@tags_layer(requirements=…)` accepts values from it;
  an unregistered requirement is a hard error at import, like an unregistered tag.
- **`OperationSpec.requirements`** — surfaced, snapshotted into `operation_catalog.json` (regenerated),
  and covered by the drift guard / regeneration check.
- **`operation_spec.runnability(spec, available)`** → `(can_run, reason)`, plus `unmet_requirements()`
  — the gating helpers. `reason` names, in human terms, exactly what is missing, so the UI can grey the
  operation out with an explanation. `inputs` remain the *layer* preconditions (the Capability
  machinery); `requirements` are the *environmental* ones — the two are complementary.
- **`tests/navigator/test_operation_requirements.py`** — the validation: vocabulary agreement, every
  requirement carries a reason, gating returns `(False, "needs …")` when unmet and `(True, "")` once
  satisfied, and a **downward-only coverage floor** (≥ 8 ops).
- **Annotated the unambiguous tranche (8 ops):** the 3D ops need a z-stack (`gaussian_3d`, `gabor_3d`,
  `dog_3d`, `bg_subtract_3d`, `cellpose_3d`, `subcellular_segment_3d`), the temporal ops need a time
  axis (`temporal_enhance`, `drift_correct`).
### Notes
- **The OperationSpec roadmap is now complete** (increments 1–5): a typed, drift-guarded spec that is a
  graph (`inputs`/`produces`), against which batch replay is auditable (`_STEP_OPERATIONS`), that the
  Navigator is *generated from* (increment 4), and that now carries runnability gating. Wiring the gate
  into actual UI widgets, and the separate `tag_resolver` binding table, remain as follow-on consumers —
  noted, not built here.
- Staged population per the increment-2 discipline; a guessed requirement is worse than none. No
  behaviour change. Full `pytest -m core` green.
- Files: `src/pycat/utils/tag_registry.py`, `src/pycat/navigator/operation_spec.py`,
  `src/pycat/navigator/op_catalog.py`, `src/pycat/navigator/data/operation_catalog.json`, the 4
  annotated toolbox modules, `tests/navigator/test_operation_requirements.py`,
  `tests/navigator/test_operation_spec_matches_catalog.py`.

## [1.6.128] - 2026-07-19
### Changed — **OperationSpec increment 4: flip the Navigator catalog from validate-against-the-spec to GENERATE-from-the-spec.**
The payoff increment 1 was built for. Increment 1 made the operation catalog a *validated* committed
snapshot; increments 2–3 turned the vocabulary into a graph and made batch replay auditable against it.
Increment 4 does the flip on the lowest-risk consumer — the **Navigator catalog** — as a proven-safe
change: the operation set is now **generated from the live spec** (`iter_operation_specs()` → the
`@tags_layer`/UI decorators) instead of read from the committed `operation_catalog.json`. The decorators
are the runtime source of truth; the JSON becomes a reviewable, shippable *artifact*, no longer
authoritative at run time — the Navigator builds correctly even if the file is absent. One subsystem
only, to prove the pattern before touching UI or batch.

- **`build_catalog_document()`** (`navigator/op_catalog.py`) — new: builds the catalog document purely
  from the live spec, no file write. `regenerate_operation_catalog()` is now a thin writer over it.
- **`build_operation_registry(from_spec=True)`** — the flip: the Navigator's layer ops are generated
  from `build_catalog_document()` by default. `from_spec=False` still builds from the committed file (an
  escape hatch for tooling); the two are **equal by construction**, so this is not a behaviour change.
- **The drift guard becomes a regeneration check.** `test_operation_spec_matches_catalog.py` now asserts
  the committed JSON *equals* `build_catalog_document()` exactly — stronger than the field-by-field
  checks (it also catches provenance/ordering/field-set drift), and it proves the committed artifact is
  the generation and nothing else. The granular coverage/field tests are kept because they name *what*
  diverged. A new test proves the registry generated from the spec is identical to the one built from
  the file.
### Notes
- **No behaviour change and no JSON churn:** the committed `operation_catalog.json` already equalled the
  regeneration (it was regenerated in increment 2), so the flip changed the *source of truth*, not the
  numbers. Full `pytest -m core` green.
- Scope held to one subsystem per the roadmap — batch (`_STEP_MAP`) and UI are untouched. The remaining
  increment is **5** (`requirements`/runnability gating, wiring the spec to `capabilities.py`), plus the
  separate binding-table effort.
- Files: `src/pycat/navigator/op_catalog.py`, `src/pycat/navigator/__init__.py`,
  `tests/navigator/test_operation_spec_matches_catalog.py`.

## [1.6.127] - 2026-07-19
### Added — **OperationSpec increment 3: declare the batch-step → operation composition, so replay is auditable against the vocabulary.**
Increment 2 made the operation vocabulary a graph. Increment 3 connects the **batch replayer** to it.
The increment-2 survey measured a key fact: `batch_step_registry._STEP_MAP` (68 workflow steps) and the
operation catalog (79 layer ops) have **zero name overlap** — a *step* is a workflow stage
(`condensate_segmentation`), an *operation* is a layer-producing transform (`subcellular_segment`). That
is correct design, not drift, so the two vocabularies are **not merged**. The honest relationship is
**composition**: a step *invokes* one or more operations. That mapping cannot be inferred, so it is now
**declared** and **drift-guarded** — rename an operation and the build breaks here instead of replay
silently breaking at run time. This is the prerequisite for ever *generating* batch steps.

- **`_STEP_OPERATIONS`** in `batch_step_registry.py` — the declared step → operation-ids map, placed
  next to `_STEP_MAP` (declared at the code, not a side table). Public accessors `step_operations(name)`
  and `all_step_operations()`. Each mapping was **verified** against the step's replay function (its
  toolbox imports → the op that function declares), never guessed.
- **`tests/navigator/test_batch_step_composition.py`** — the validation: every declared op exists in
  the operation vocabulary (catalog layer ops ∪ curated measure ops), every declared step is a real
  `_STEP_MAP` key, no empty declarations, ids are a deduplicated lower-case set, and a **downward-only
  coverage floor** (≥ 10 steps mapped). Undeclared steps are *reported*, not hidden.
- **Declared the unambiguous tranche (10 steps):** `preprocessing`→`preprocess`, `upscaling`→`upscale`,
  `calibration_correction`→`flatfield`/`bg_subtract_clear`, `auto_crop_roi`→`multi_otsu`,
  `cellpose_segmentation`→`cellpose`/`stardist`, `condensate_segmentation` & `ivf_segmentation`→
  `mask_stretch`/`subcellular_segment`, `bf_condensate_segmentation` & `ivbf_segmentation`→`bf_segment`,
  `ivf_size_distribution`→`invitro.size_distribution` (a measure op).
### Notes
- **Staged population, deliberately** (the increment-2 discipline). Steps that invoke only an *untagged*
  composite (e.g. `background_removal`'s rolling-ball path has no op id) or only file I/O
  (`open_image`, `save_and_clear`) are **left undeclared**, not mapped to a guess — a guessed mapping is
  drift with extra steps. The ratchet floor captures progress; later work raises it. `_STEP_MAP` and the
  catalog remain separate vocabularies by design (composition, not merger).
- No behaviour change. Full `pytest -m core` green.
- Files: `src/pycat/batch_step_registry.py`, `tests/navigator/test_batch_step_composition.py`.

## [1.6.126] - 2026-07-18
### Added — **OperationSpec increment 2: `inputs` on the decorator — the operation vocabulary is now a GRAPH.**
Increment 1 defined `OperationSpec` as a typed, drift-guarded view over the `@tags_layer` registry and
deferred the richer fields with an explicit rule: *a field nothing checks is exactly the drift this
effort exists to prevent.* Increment 2 adds the first of those fields — `inputs`, the layer
role(s)/target(s) an operation **consumes** — **with the validation that makes it real**. Together with
the existing `produces`, `inputs` turns the flat list of 79 operations into a directed graph
(`op_a.produces → op_b.inputs`). Increments 3–5 (batch composition, subsystem generation, runnability
gating) all need that graph. Additive; no behaviour change.

- **`@tags_layer(inputs=…)`** (`utils/tag_registry.py`) — optional, drawn from the **same** `ROLES` /
  `TARGETS` vocabularies (never a third one); an unregistered input value is a hard error at import,
  exactly as an unregistered tag already is. An operation that declares nothing is a *root* (it loads
  or creates a layer from a file). `_register_ui_operations()` gained the same ability.
- **`OperationSpec.inputs: tuple[str, ...] = ()`** (`navigator/operation_spec.py`) — populated from the
  registry in `iter_operation_specs()`, added to `operation_catalog.json` (regenerated), and covered by
  the existing drift guard (`test_catalog_fields_match_the_live_declaration` now also compares `inputs`,
  so a declared-vs-snapshot divergence fails like any other field).
- **`tests/navigator/test_operation_graph.py`** — the validation: no dangling edges (every declared
  role input is produced by some op or is the root role `image`), vocabulary agreement, traversability
  from the roots (an unreachable input-bearing op is *reported*, not failed), a real op→op edge exists,
  and a **downward-only coverage floor** (≥ 23 ops declare `inputs`) so the declarations populate
  incrementally without silently regressing.
- **Annotated the unambiguous image-consuming tranche (23 ops):** the filters that consume an image and
  return one (`bandpass`, `dog`, `log`, `rolling_ball`, `gaussian`, `bilateral`, `gabor`, `invert`,
  `wbns`, `bg_subtract`, `tone_map`, `local_contrast`, `clahe`) and the primary segmenters/detectors
  that consume an image (`cellpose`, `subcellular_segment`, `local_threshold`, `felzenszwalb`,
  `felzenszwalb_binary`, `multi_otsu`, `clean`, `bead_detect`, `bf_segment`, `host_segment`) — all
  `inputs=('image',)`.
### Notes
- **Staged population is deliberate.** The remaining ~56 ops (label-editors, merges, ambiguous-input
  ops) are **not** annotated: declaring an input you had to guess is drift with extra steps. The ratchet
  floor captures progress; later work raises it. `batch_step_registry._STEP_MAP` was left untouched — it
  names workflow steps, not layer operations, and has zero overlap with the catalog (composition is
  increment 3).
- No `parameters` / `batchable` / `requirements` in this increment. Full `pytest -m core` green.
- Files: `utils/tag_registry.py`, `navigator/operation_spec.py`, `navigator/op_catalog.py`,
  `navigator/data/operation_catalog.json`, the 8 annotated toolbox modules,
  `tests/navigator/test_operation_graph.py`, `tests/navigator/test_operation_spec_matches_catalog.py`.

## [1.6.125] - 2026-07-18
### Added — **Cross-route workflow-equivalence matrix: the same workflow must give the same numbers through every route.**
The strongest recommendation from the external architecture audit: stop adding isolated per-route
tests and start asserting that a canonical workflow produces the **same numbers** through headless call,
batch replay, and session reload. PyCAT exposes each operation through several routes, each of which
assembles its parameters independently — and a disagreement is the highest-severity class of bug PyCAT
can have (the same analysis silently yielding different numbers depending on how it was launched). This
is not hypothetical: batch preprocessing once passed a *normalised* image where the interactive path
passed **raw counts**, and the rolling-ball radius is not scale-invariant. This generalises the test
written for that one step into a matrix.

- **`tests/route_equivalence.py`** — the reusable harness. `run_all_routes(workflow)` drives a workflow
  through each route; `assert_routes_agree(...)` compares each route against a reference and **names
  which route diverged and by how much**. A route that cannot be driven headlessly is declared an
  `Unavailable` **documented gap** — and the harness fails if a gap closes or a route vanishes without
  the record being updated, so nothing is skipped silently. Comparators default to **exact** equality.
- **`tests/test_route_equivalence.py`** — three canonical workflows, distinct data shapes:
  1. **Rolling-ball background removal** (the known-divergence scale-semantics path) — runs all three
     routes and they are **bit-identical**. Both headless and batch reduce to the same toolbox call on
     the same *raw counts*; if the batch replay ever reverts to normalising first, this names it.
  2. **Puncta detection + measurement** — headless ≈ session (the per-object table survives Save &
     Clear → reopen unchanged). Batch is a **declared gap**: its replay needs a cellpose cell mask
     upstream (torch), absent in the headless `core` env.
  3. **VPT tracks → MSD → viscosity** — headless ≈ session, plus an **end-to-end check that the chain
     recovers the known Stokes–Einstein viscosity** (η = kT/6πRD; true D=0.05 → η recovered within
     15% on the noisy synthetic chain). Batch is a declared gap: VPT/MSD replay steps are deliberate
     skip-stubs (time-series, not per-image).
- Adding a fourth workflow is one `Workflow(...)` entry.
### Notes
- **Finding (minor, not fixed here — test-only spec):** a session-restored DataFrame comes back with an
  extra `Unnamed: 0` column — `write_session_outputs` writes the DataFrame index into the CSV, which
  reloads as a phantom column. The scientific numbers are untouched (the harness compares the named
  columns), but the schema gains a column across a save/load. Logged for a future writer fix
  (`to_csv(index=False)`); it is not a numeric divergence.
- **Justified tolerance:** the session route is a *decimal* (CSV) serialization, so a float64 can return
  differing in its last bit (~1 ULP, ~2e-16). That is the text format's precision, not a route
  computing a different number, so the DataFrame comparator allows 1-ULP-scale (rtol 1e-12, atol 1e-15)
  — far below any scientific significance. The array routes (rolling-ball) are compared **exactly**.
- No production code changed. If a real divergence is ever found, it is a finding to report with its own
  spec — not something to absorb by loosening a tolerance.

## [1.6.124] - 2026-07-18
### Fixed — **Tag discovery: kill the stale TEST loader (the "registry regressed" false alarm) and carry a declared `produces` onto the layer.**
An external audit reported the operation registry had *regressed* — "only 42 of ~100 operations
register," "`log` is missing," "duplicate registration is not rejected." **Three of those four findings
were artefacts of a defect in the test, not the product.** `tests/test_tag_registry.py` reimplemented
operation discovery with a hardcoded list of **11** module names inside `except Exception: pass`, missed
**7** decorated modules, counted 42 instead of the real **79**, and swallowed the import failures that
would have said why. Meanwhile `operation_spec._populate_registry()` already AST-discovers every
`@tags_layer` module correctly — the test had a second, worse loader.

- **Fix 1 (the real defect).** The tag tests now discover through the one mechanism —
  `_populate_registry()` — and an unimportable decorated module is a **loud failure**, not a quiet
  undercount. The `>= 50` floor is ratcheted to the live count (**79**). Routed both
  `test_tag_registry.py` and `test_tag_resolver.py` through it; deleted their private loaders. The four
  reported failures pass **with no product change** — the proof that findings 1–3 were test artefacts.
- **Fix 2 (the one genuine gap).** `layer_tag_hook` already copies a known op's declared `target` onto
  the layer (so `role=labels, target=cell` finds Cellpose output). It now also honours a declared
  `produces`: an op that **declares** it makes a mask is tagged `role=mask` even when the returned
  array's values happen to look like labels — the declaration is definitional, the data-shape is a
  guess, and declared beats inferred.
### Added
- An **end-to-end** acceptance test (`operation → layer → tags → resolver`) over six canonical ops —
  `cellpose`, `watershed`, `log`, `dog`, `rolling_ball`, `mask_merge` — each found by a query written in
  its *declared* semantics. `mask_merge` is fed labels-like data on purpose, so only the declared
  `produces='mask'` makes the `role=mask` query match.
### Notes
- **The record, stated plainly:** the "42 operations / missing `log` / no `TagCollision`" findings were a
  stale test loader. The registry holds 79 operations, `log` is present (in `image_processing_tools`,
  alias `laplacian_of_gaussian`), and `register_operation` does raise `TagCollision`. No operations were
  added to "fix" the count — that would have created the real collision the audit wrongly reported.
- Files: `src/pycat/utils/layer_tag_hook.py`, `tests/test_tag_registry.py`, `tests/test_tag_resolver.py`.

## [1.6.123] - 2026-07-18
### Changed — **Retrofit the linked-selection dock to the SelectionView contract.**
Continuing the Gap 5 retrofit (the table and the new pyqtgraph backend already conform): the dock's
`LinkedSelectionWidget` is now a `SelectionView` — `view_id`, `apply_selection(state)` (renamed from
`_on_selection`, alias kept), and a `close()` that **unsubscribes** and then Qt-closes. It is a
*receive-only* view (it renders the selected object's crop but never emits a command), so it needs no
programmatic guard.

- **Fixes a small lingering-subscription gap**: the outer wrapper's `close()` only removed the dock
  widget; the deferred subscription lived on (held weakly, so eventually GC'd, but never explicitly
  dropped). The wrapper now closes the widget, which unsubscribes.
### Notes
- Headless-tested: the widget satisfies the `SelectionView` protocol, applying a selection emits no
  command (receive-only), and `close()` unsubscribes. Existing dock tests green.
- Retrofit status: the emitting adapters (**table**, **pyqtgraph**) and the receive-only **dock** now
  conform to and are tested against the shared contract. The MSD-plot brushing works correctly but is
  registry/handler-based (rewritten in 1.6.120); formalizing it as a `SelectionView` class is a larger
  restructure of just-rewritten code — deferred rather than churned.

## [1.6.122] - 2026-07-18
### Added — **PyQtGraph 'explore' plot backend, built on the SelectionView contract.**
A fourth plot backend (alongside matplotlib/seaborn/plotly): a native-Qt interactive scatter. napari
is Qt, so a click is a Qt signal in the same event loop — no WebEngine bridge, low latency at large N.
matplotlib stays the export/publication backend; this is the interactive *explore* one.

- **`scatter(df, x_col, y_col, backend='pyqtgraph')`** returns a `PlotWidget` + `ScatterPlotItem`
  whose points map **1:1 to df rows in order** — it runs the same `_verify_row_order` guard the other
  backends do and REFUSES (`ok=False`) rather than wire a click that could land on the wrong object.
  `hue` colours per group but keeps ONE scatter item in row order (never the seaborn split-into-artists
  trap). `'pyqtgraph'` is in `BACKENDS` and `available_backends()`; the seam degrades with a message,
  not a crash, when the extra is absent.
- **Brushing is a proper `SelectionView`** (`PyQtGraphScatterView`): a click emits one command
  (`source_view`), an inbound selection highlights the matching points on a *separate overlay* item
  (O(1), not a recolour of N) under the `ProgrammaticGuard`, and `register_view` pushes current state
  on open. It passes the **same shared adapter contract** the table and the reference view pass — no
  second selection path. Camera-follow stays opt-in (the VPT-P3 no-loop lesson).
- **Optional dependency** `pip install pycat-napari[pyqtgraph]`; imported lazily, so PyCAT still
  imports and runs headlessly without it (the headless-import contract holds).
### Notes
- Rebuilt on `main` against the interaction-layer contract rather than merging the stale
  `pyqtgraph-backend` branch (which predated `SelectionView` and used the old bare-callback API — the
  exact rewrite the spec's ordering was meant to avoid). That branch is now superseded.
- Headless-tested: availability, backend registration, graceful absence, row-order 1:1, hue single-
  artist; and with Qt: the scatter maps to rows, an inbound selection highlights the overlay, and the
  adapter passes the SelectionView contract. Core: 1018 passed. **A live interactive click is worth a
  viewer once a UI opts a panel into this backend** — nothing uses it by default yet.

## [1.6.121] - 2026-07-18
### Added — **Interaction layer 5: a `SelectionView` adapter contract.**
Linked views used to be bare callbacks with no shared contract, so each re-invented apply / suppress /
cleanup and they drifted. This adds the contract, its mechanism, and a reusable test — the piece the
pyqtgraph plot backend should be built against.

- **`SelectionView` protocol** (`selection_service.py`): `view_id`, `apply_selection(state)`, `close()`.
- **`ProgrammaticGuard`** — the *primary* echo defence: `with guard.applying(): <render>` marks a
  programmatic update, and the view's outbound handler checks `is_applying` and bails, so rendering a
  selection never emits a new command (the service's `source_view` suppression stays as the second
  line). Re-entrant.
- **`register_view(service, view)`** subscribes the view AND pushes the current state, so a view opened
  while something is already selected reflects it — the initial apply is programmatic (emits nothing).
- **A reusable contract** (`tests/selection_view_contract.py`): programmatic apply emits no command, a
  user action emits exactly one, an unknown entity is safe, and `close()` unsubscribes. A reference
  adapter and the **retrofitted `BrushableTable`** (now a `SelectionView`: `apply_selection` + `close`,
  using the shared guard + `register_view`) both pass it. `detach()` stays as a back-compat alias.
### Notes
- Headless-tested: the guard is re-entrant, `register_view` pushes current state without bouncing a
  command, and both the reference adapter and the real Qt `BrushableTable` pass the shared contract.
  All existing selection/brushing tests green.
- Completes the interaction-layer spec's mechanism. Remaining, additive: retrofit the other views (MSD
  plot, VPT handlers, napari overlay, dock) to the protocol, then build/merge the pyqtgraph backend
  adapter against this contract.

## [1.6.120] - 2026-07-18
### Changed — **Interaction layer 4: MSD spaghetti background is one `LineCollection`; selection is an overlay.**
Hundreds of individual `Line2D` are slow to draw and force a per-artist style restore on every
selection. Both MSD renderers — the standalone `plot_msd_trajectories` and the consolidated panel's
`_draw_msd_into` — now draw the representative background as a **single `LineCollection`**, and a
selection (or a track brushed in from the table, Gap 3) is drawn as an **overlay `Line2D`** on top.
The background collection is never touched.

- **Hit-testing is on the coordinate ARRAYS**, not per-line artists (`_connect_nearest_curve_click_coords`
  + `_segment_distance_px_coords`), so collapsing the background costs nothing for interaction — same
  nearest-curve + click-to-cycle behaviour as before.
- **One implementation, shared:** the new `_msd_overlay_hooks` sets up promote/demote + the click
  hit-tester for BOTH renderers, so the standalone plot and the consolidated panel brush identically
  instead of from two divergent copies (the panel's bespoke blit + apply-pick + connect are gone).
  With one background artist a redraw is cheap, so selection uses a plain `draw_idle` — no blit
  bookkeeping. VPT's `_highlight_track_in_plot` was already overlay-aware (1.6.119) and needs no change.
### Notes
- Headless-tested: the background is exactly one `LineCollection` (not N `Line2D`); selecting a track
  adds one overlay and leaves the collection untouched; demote removes only the overlay; a non-sampled
  track promotes from the full frame; the log axes still frame the data (the `LineCollection`/log
  autoscale pitfall is checked); and the coords hit-tester matches the Line2D point-to-segment
  geometry. Consolidated-panel path smoke-checked end-to-end. **The live feel needs a viewer:** confirm
  clicking/​table-selecting a track in both the standalone and consolidated MSD plots still highlights
  correctly and feels responsive.
- Completes the interaction-layer spec's structural gaps (1=state 1.6.118, 3=promotion 1.6.119, 4=this;
  2=honest hit-testing was 1.6.100). Gap 5 (the `SelectionView` adapter contract, which lets the
  pyqtgraph backend be built correctly) remains as a separate pass.

## [1.6.119] - 2026-07-18
### Added — **Interaction layer 3: a track selected from the table shows even if it isn't in the MSD sample.**
The MSD spaghetti plot draws a fidelity-targeted representative subset (~100 of N), so a track picked
in the table that wasn't sampled had no curve to highlight — the bidirectional brushing quietly
couldn't reach it. Now the plot promotes it on demand.

- `plot_msd_trajectories` registers `promote(tid)` — draws a non-sampled track's curve on demand and
  returns its line — and `demote_line(line)` — removes it when it's deselected. A **sample line is
  never removed**; only a promoted focus curve is. The displayed set is effectively
  `representative_sample ∪ selected`.
- **VPT's `_highlight_track_in_plot` uses them**: when a selected track has no line it promotes one;
  when the previously-highlighted track was a promoted curve it demotes (removes) it instead of
  restyling. A promote/demote changes the line set, so it does a full redraw there rather than the
  blit fast-path (which assumes a fixed set); highlighting a sampled track still blits.
### Notes
- Headless-tested against the real `plot_msd_trajectories`: only the sample is drawn up front, a
  non-sampled track promotes (and is marked a focus curve), a sampled track's promote returns its
  existing line, demote removes a promoted curve but NEVER a sample line, and a track with no data
  promotes to nothing. **The live feel needs a viewer:** confirm selecting a table row for a track
  outside the sample now highlights its curve, and deselecting removes the promoted curve.
- Builds on 1.6.118's `SelectionState`. Gap 4 (`LineCollection` background) and Gap 5 (the
  `SelectionView` adapter contract) remain as separate additive passes.

## [1.6.118] - 2026-07-18
### Added — **Interaction layer 1: selection is now a hover / selected / pinned STATE.**
First increment of the interaction-layer spec. Selection was a single object — no multi-select, no
pinning while exploring, no independent hover. `SelectionService` now holds a `SelectionState`
(`selected: frozenset`, `primary`, `hovered`, `pinned: frozenset`, `generation`) and publishes the
whole state per change, with commands that produce a new one:

- `toggle(entity, source)` — ctrl-click to build a comparison set; `select_entity` — single select;
  `hover(entity, source)` — independent of selection; `pin`/`unpin` — survive a clear;
  `clear_selection(source)` — clears selected + hovered but **keeps pins** (Escape's semantics).
- **Back-compat is total.** `SelectionState` quacks like the old `Selection` (`entity_ids`,
  `primary_id`, `source_view`, `is_empty`), so every existing subscriber (the dock, the VPT views, the
  plots) and every existing test keeps working unchanged — the dispatch core (busy-guard, delayed
  release, deferred-debounce) is untouched, just extracted into `_publish` and shared by the old
  `select(Selection)` entry and the new commands.
### Notes
- Headless-tested: toggle add/remove, clear keeps pins, hover doesn't disturb selection, one command =
  one generation = one publish, a command reaches old subscribers via the back-compat interface, and
  the source view is skipped. All 72 existing selection/brushing tests still green.
- This is the keystone the pyqtgraph plot backend should be built against (its adapter must speak this
  state, not the old bare callback). Remaining interaction-layer increments (honest hit-testing —
  largely done in 1.6.100 via click-cycling; non-sampled track promotion; `LineCollection` background;
  the `SelectionView` adapter contract) are separate, additive passes.

## [1.6.117] - 2026-07-18
### Fixed — **CZI exit hang: force the exit from `atexit`, not `aboutToQuit` (which never fired).**
1.6.116's `QApplication.aboutToQuit` hook did not fix the hang — it is installed from the CZI-open
**worker thread**, where a cross-thread Qt `connect` is unreliable, and the hang is at Python
interpreter shutdown *after* `napari.run()` returns. Moved the guarantee to an **`atexit`** handler:
it runs on the main thread right before Python joins the JVM's non-daemon threads (exactly where it
hangs), so `os._exit(0)` there terminates cleanly. `aboutToQuit` is kept as a best-effort earlier
trigger.
- The handler prints `[PyCAT CZI] BioFormats JVM was open — forcing a clean process exit…` when it
  runs, so it is visible whether the fix engaged. Verified at the process level: the standalone reader
  fires the handler and exits `0`.
- Only the welcome-logo temp-file cleanup atexit is pre-empted (harmless), and only in a CZI session.
### Notes
- **Needs a viewer:** open the streaming `.czi`, close PyCAT — you should see that force-exit line and
  get the prompt back. If you close and do NOT see the line, `napari.run()` isn't returning on close
  and I'll hook the viewer's window-close event instead.

## [1.6.116] - 2026-07-18
### Fixed — **Closing PyCAT after a CZI now returns the terminal (force a clean exit).**
Headless mode (1.6.115) was not enough: something in the napari/Qt + BioFormats-JVM combination still
keeps the process alive at teardown — the window closes but the terminal never comes back. It cannot
be reproduced outside the GUI (a plain script exits fine), so rather than keep chasing which Java/Qt
thread refuses to die, PyCAT now forces a clean termination at the app's quit point: **once a CZI has
started the JVM**, `QApplication.aboutToQuit` flushes the streams and calls `os._exit(0)`. This only
arms in a CZI session (the JVM-start path) — every other session exits normally — and it runs after
other quit handlers, so it is the last thing before the process would otherwise hang.
### Notes
- **Needs a viewer:** confirm closing PyCAT after opening the streaming `.czi` returns the prompt.
- Keeps the 1.6.115 headless start (good practice regardless) and the scrubbing findings from
  1.6.113–115 (the stutters are inherent BioFormats seek latency).

## [1.6.115] - 2026-07-18
### Fixed — **Closing PyCAT after opening a CZI no longer hangs the terminal.**
Long-standing: after opening a streaming CZI, closing PyCAT left the process alive — the window shut
but the terminal never returned. Reading a CZI can make BioFormats touch Java AWT (colour models /
thumbnails), which spawns a **non-daemon AWT thread** that keeps the JVM — and the whole Python
process — running at shutdown. (A plain script exits fine because it never triggers AWT the way the Qt
app does, which is why it only bit inside `run-pycat`.) The JVM is now started **headless**
(`scyjava.config.enable_headless_mode()` → `-Djava.awt.headless=true`), so no AWT thread is ever
created; BioFormats reads pixels and metadata without it (verified: the reader still opens/reads the
real 8 GB file and the process exits cleanly).
### Diagnostics
- The `PYCAT_CZI_TRACE=1` readout now breaks latency into **worst lock-wait** and **worst openBytes**.
  On the real file this settled the scrubbing question: worst lock-wait **1–2 ms** (the prefetcher is
  not blocking foreground reads) and worst openBytes **~400 ms** — i.e. the intermittent stutters are
  BioFormats **seeking to distant frames**, an inherent random-access cost of this streaming CZI that
  caching cannot remove. The prefetch (1.6.114) is correct and harmless but only helps when scrubbing
  pauses or revisits cached frames; it cannot get ahead of a continuous drag through new frames.
### Notes
- **Needs a viewer:** confirm that opening the streaming `.czi` and then closing PyCAT returns the
  terminal prompt (no more reopening the terminal).

## [1.6.114] - 2026-07-18
### Changed — **CZI prefetch: foreground-priority + direction-aware (fixes back-and-forth scrubbing).**
An audit of 1.6.113's prefetch found it structurally wrong for anything but forward playback: it
published the current frame to the prefetcher only AFTER reading it, prefetched forward-only, and could
hold the reader's lock on obsolete frames while the UI waited on the one frame the user actually moved
to. Redesigned per that audit:

- **Foreground priority.** A read now publishes its request (target + a monotonic generation) and
  raises `_fg_pending` *before* it reads, so the background thread never starts a read while the UI is
  waiting, and abandons an obsolete read-ahead pass the moment a newer request arrives.
- **Direction-aware read-ahead.** The prefetcher follows the scrub: forward for a forward scrub,
  **backward for a backward scrub** (previously all-misses), a symmetric neighbourhood when direction
  is unknown or the frame is held, and a shallow ±2 on a large jump (no far speculation).
- **Buffer-layout guard.** `_read_plane_raw` now asserts the BioFormats plane byte-count matches
  `H·W·itemsize` and reports series/RGB/interleaved on mismatch — a wrong series or layout can be the
  wrong size in a way that still reshapes to a shifted image; this fails loudly instead.
### Notes
- Correctness investigation (the reported "seam"): BioFormats reports the streaming file as a **single
  series, single resolution, 500×500 uint16, non-RGB, non-interleaved**, and plane 0's buffer is
  **exactly** 500·500·2 bytes — so the reader selects the right series and the byte→array reshape is
  correct. The residual ~1.5% column-12 step and the anomalous row 0 are constant across frames and
  are in the acquisition, not the decode. No pixel-decoding defect.
- Benchmarked on the real 8 GB file at 25 fps: forward **40/40**, backward **40/40**, oscillate
  **36/36** frames served from cache (0 ms). **Needs a viewer:** confirm scrubbing actually feels
  smooth — set `PYCAT_CZI_TRACE=1` before `run-pycat` to print the real per-scrub cache hit-rate and
  read latency. If the trace shows high hit-rate but the viewer still lags, the bottleneck is napari's
  render path, not the reader.
- Deferred (audit #3): caching native uint16 for display and normalising to float32 only for analysis
  — would roughly double the cached temporal span, but it splits the display/analysis representation
  and cuts against PyCAT's uniform `[0,1]` loader contract, so it wants its own pass.

## [1.6.113] - 2026-07-18
### Changed — **Streaming CZI scrubbing is smooth: an LRU cache + background read-ahead.**
The direct BioFormats reader decodes ~5 ms/plane, which showed as intermittent stalls scrubbing the
15,766-frame movie frame by frame. The reader now caches planes and reads AHEAD.

- **Byte-budgeted LRU cache** (256 MB → 268 planes at 500², 16 at 2048²) so repeats and small back-and-
  forth scrubs are instant.
- **Background read-ahead**: a single worker thread decodes the next few frames (`_PREFETCH_AHEAD = 8`)
  ahead of the frame last accessed, and bails the moment the user moves on, so a forward scrub lands on
  already-decoded planes. Measured on the real 8 GB file: a 25 fps forward scrub was served **30/30
  from cache (0 ms)**, versus ~5 ms/plane cold.
- Every read (foreground + prefetch) is serialised on one lock — a loci `ImageReader` is not safe for
  concurrent `openBytes` — held per plane (~5 ms), so a foreground miss never waits long.
- **The prefetch thread detaches from the JVM whenever it goes idle.** A JNI thread that attached (via
  `openBytes`) and never detached blocks `DestroyJavaVM`, hanging the whole process at exit — found and
  fixed here; the process now exits cleanly.
### Notes
- Headless-tested: cache hit on repeat, read-ahead caches the frames ahead, the cache is byte-budgeted,
  and close stops the prefetcher. The reader was also run end-to-end on the real 8 GB file (opens,
  reads, prefetches frame 101 after frame 100, exits cleanly). **Needs a viewer:** confirm scrubbing
  the streaming `.czi` is now smooth with no intermittent stalls.
- The `@integration` real-file test hit an intermittent jpype `startJVM` access violation in this
  session's harness (unrelated to this change — the prefetch thread starts *after* JVM init, and it is
  deselected from the core suite); the reader is verified via the standalone benchmark above.

## [1.6.112] - 2026-07-18
### Fixed — **The CZI open no longer cancels itself.**
A regression in 1.6.111 (unreleased): opening the streaming CZI reported "CZI open cancelled" and
aborted on its own, with no user interaction. `QProgressDialog.close()` **emits `canceled`**, so when
the dialog closed on *normal completion*, the cancel handler fired and marked the load cancelled.

- The finish handler now marks completion (`done`), and the cancel handler ignores the
  `canceled` that `close()` emits once the work is done — only a real "Give up" click (or Escape/X)
  *before* completion cancels. Regression-tested (`test_busy_progress.py`, real Qt loop): a successful
  call returns its value instead of raising the cancellation.
### Notes
- Rolls up with 1.6.110 (dedupe + off-thread libCZI probe) and 1.6.111 (dialog auto-closes + "Give
  up"). **Needs a viewer:** confirm the streaming `.czi` now opens to completion on its own, and "Give
  up" still cancels cleanly.

## [1.6.111] - 2026-07-18
### Fixed — **The CZI "indexing" dialog now closes itself, and "Give up" actually works.**
From the viewer, on the streaming-CZI open dialog: it stayed open with the elapsed counter frozen, and
only advanced when the user X'd it out; there was no cancel button; and X-ing out early hung the UI.
All three are the same worker-dialog helper (`_run_with_busy_progress`), which had the exact bug the
newer `qt_worker` was built to avoid.

- **It closes when the work finishes.** `worker.finished` is emitted from the worker thread, and the
  old finish handler was a plain function — so Qt ran it *on the worker*, and `dlg.reset()` from there
  never ended the main thread's modal loop. The dialog hung open (frozen elapsed = work already done)
  until the user dismissed it. The handler is now a `QObject` slot that runs on the main thread (queued
  delivery), ending a `QEventLoop` with `loop.quit()`.
- **A "Give up" button that frees the UI.** The BioFormats index parse is a single uninterruptible JVM
  call, so cancel **detaches**: it stops waiting and lets the orphaned worker finish in the background
  (result dropped), instead of `thread.wait()` blocking the UI until the parse completes — which was
  the hang when X-ing out. The detached thread is retained until it finishes so it can't crash by being
  garbage-collected mid-run. Both CZI open sites report "CZI open cancelled." and abort cleanly.
### Notes
- Same fix benefits both CZI busy dialogs (the libCZI index probe and the BioFormats reader open).
  **Needs a viewer:** confirm the indexing dialog now closes on its own and the layer appears without
  X-ing out, and that "Give up" dismisses it and frees the window immediately.
- Still open, deliberately (secondary): the occasional scrubbing latency on the streaming movie —
  that's the prefetch/cache task (read T±k around the current frame), separate from this dialog fix.

## [1.6.110] - 2026-07-18
### Changed — **Opening a big streaming CZI no longer freezes the UI on the libCZI probe.**
The streaming-CZI reader (BioFormats, shipped 1.6.61) already opened its Java reader off-thread — but
the libCZI **metadata** open that routes to it ran on the Qt main thread, and for a 15,766-frame movie
parsing every subblock offset is ~11 s. Worse, it ran **twice**: once to decide the file needs
BioFormats, then again inside the streaming loader for pixel size / channel names. So ~20 s of "Not
Responding" preceded the (already responsive) BioFormats indexing dialog.

- **The two libCZI opens are deduplicated.** The routing probe (`probe_libczi`) now returns the libCZI
  image alongside its can-read verdict, and the streaming loader reuses it instead of re-opening —
  the multi-second subblock parse is paid once.
- **For a large CZI the probe runs off the Qt thread** behind the existing busy dialog, so even the
  first parse stays responsive. A small confocal/widefield CZI (a few MB, parses in milliseconds) still
  probes inline — a worker dialog would only flash. The gate is file size (`_CZI_OFFTHREAD_BYTES`,
  256 MB), which sits far below any streaming movie and far above any normal CZI.
### Notes
- No change to the reader itself or to normal-CZI behaviour (still libCZI, fast, no JVM). Verified: the
  streaming reader still opens and reads the real 8 GB file (integration test), and `probe_libczi`
  returns the image even when the pixel read fails (headless tests). **Needs a viewer:** confirm
  opening the streaming `.czi` shows the responsive indexing dialog from the start, with no initial
  freeze.
- Housekeeping: the CZI reader was fully built and shipped in 1.6.61 but never got a CHANGELOG entry;
  this documents the format is supported (confocal/widefield via libCZI; Zeiss fast-streaming via the
  opt-in `[bioformats]` extra).

## [1.6.109] - 2026-07-18
### Fixed — **QC on a long movie no longer OOMs; it assesses a bounded sample.**
With the IMS decode fixed (1.6.108), QC got further and then hit a *second* out-of-memory: `run_full_qc`
upcast the whole stack to **float64** (18.8 GiB for a 600×2048² movie), and even at float32 the
per-metric transients (`qc_snr`'s `np.diff` over every frame) are multi-GiB. Both were pre-existing and
independent of the off-thread work. Three parts:

- **QC now assesses an evenly-spaced sample of a long time series**, capped at `QC_MAX_FRAMES` (64).
  The UI reads **only those frames** off disk (`materialize_stack(max_frames=…)` indexes them via
  `__getitem__`), so a 600-frame movie costs ~1 GiB instead of ~18 GiB. QC is a health check, so an
  evenly-spaced sample across the acquisition answers it — and the report now carries a **"Frames
  assessed: N of M"** row that says so, and flags that the sampling lowers the rate the vibration check
  sees (drift, bleaching and focus are sampled across the whole run and unaffected).
- **`_to_float` casts to float32, not float64** — ample precision for every QC metric, half the memory,
  and a no-op (no copy) for a stack already decoded as float32.
- **The 3-D check reads the SHAPE, not a `.ndim` attribute.** The IMS readers advertise a `(T, Y, X)`
  shape but no `ndim`, so `getattr(_layer_data, 'ndim', 2)` read them as 2-D and fell to
  `np.asarray(wrapper)` — the lazy-guard refusal, i.e. the original crash. QC now derives 3-D from the
  shape and takes the decode path.
### Notes
- Headless-tested: `materialize_stack(max_frames=…)` returns evenly-spaced frames and reads ONLY those
  (endpoints included); `_to_float` is float32 and copy-free; the QC report adds the sampling note only
  when it actually subsampled. **Needs a viewer:** confirm QC now completes on the 600-frame .ims with
  the report showing "64 of 600 frames".
- **Judgment call worth your eye:** the sample is *strided* (spans the whole acquisition), which keeps
  drift/bleaching/focus honest but lowers the vibration check's frequency range. If you'd rather QC use
  a contiguous native-rate window (vibration correct, drift only over the window) or raise the 64-frame
  cap, say so — both are one-line changes.

## [1.6.108] - 2026-07-18
### Fixed — **`materialize_stack` could not read the IMS readers (QC crashed on an .ims stack).**
Running QC (or any full-stack analysis) on a lazy `.ims` movie raised
`RuntimeError: An implicit full-stack read was attempted on _ImsReaderTYX`. **A pre-existing bug the
1.6.107 off-thread change surfaced** by re-raising it cleanly instead of swallowing it: the old QC
code called the same `materialize_stack`.

- `materialize_stack` is the *sanctioned* full-read path, but for a lazy wrapper without
  `as_full_array` it fell through to `np.asarray(stack_like)` — and the IMS readers' `__array__` now
  **refuses** an implicit full read (`lazy_guard.refuse_implicit_full_read`) rather than truncating to
  one frame, so the blessed reader raised the very error it exists to prevent. It now reads any 3-D
  indexable wrapper **frame by frame via `__getitem__`** (guard-safe, the same access the guard's own
  message points to), keyed on shape before it ever touches `np.asarray`. Plain numpy / dask /
  `as_full_array` wrappers are unchanged. Regression-tested with a wrapper that refuses `__array__`.
### Changed — **Data Quality Control moved to the top level of Analysis Methods.**
It was tucked inside **Toolbox → Data Visualization**, which is both hard to find and conceptually
wrong — QC is the first thing you do to a dataset, not a plot. It is now a top-level item in the
**Analysis Methods** menu, next to Exploratory Analysis. (Per-frame **Frame Quality / Focus QC** stays
under Data Visualization; that is the different, per-frame scorer.)
### Notes
- Headless-tested: `materialize_stack` reads a guard-refusing 3-D wrapper frame-by-frame, preserves
  label dtype, and drives the progress callback. **Needs a viewer:** confirm QC now runs on the .ims
  stack (with the modal decode dialog), and that Data Quality Control appears at the top of Analysis
  Methods.

## [1.6.107] - 2026-07-18
### Changed — **Every widget's stack decode runs off the Qt thread now.**
The other half of 1.6.106. Fourteen sites across eight widgets decoded a lazy stack with
`materialize_stack` on the Qt main thread — the 1.6.81/82 progress bars made that wait visible (a
synchronous `repaint()` advances the bar) without making it shorter, so the window could still say
"Not Responding" while the bar moved. All fourteen now decode through a worker.

- **New `qt_worker.materialize_off_thread(layer.data, viewer=…, **kw)`** wraps `materialize_stack` in
  `run_with_progress`: the decode runs on a `QThread` behind a modal dialog, and the array comes back
  on the caller's thread — safe to hand straight to analysis, exactly as before. `dtype=` and any other
  kwargs pass through unchanged.
- **Converted:** FRAP (recovery + pre-bleach), condensate-physics (fusion + QC), data-QC,
  brightfield (dynamics + focus-QC), in-vitro fluorescence (dynamics + intensity + QC), in-vitro
  brightfield (dynamics + focus-QC), fusion (image mode), and the temperature module's shared cached
  `_get_stack` (which froze once, on whichever section was clicked first). The inline `PhasedProgress`
  bars for the decode phase are retired in favour of the modal dialog.
- **Not converted:** FRAP's 2-D per-candidate scan (`_offer_stack_2d_images`) — it decodes single 2-D
  frames in a loop, where an off-thread dialog would flash once per candidate. It stays synchronous and
  is the one excused entry.
### Notes
- The progress-rollout ratchet (`test_progress_rollout.py`) is rewritten for the new contract: a
  `*_ui.py` that decodes a stack **directly** (synchronously, on the Qt thread — bar or no bar) now
  fails; the way to pass is to route it through `materialize_off_thread`. The countdown is at zero.
- Headless-tested: the helper decodes via `materialize_stack` on the worker, passes kwargs and a
  callable progress callback, and survives a viewer with no Qt window; plus the five real-thread
  integration tests (work off-main, value back on-main, progress crosses to main, errors re-raise,
  threads cleaned up). **The per-widget feel needs a viewer** — confirm a dynamics/QC/FRAP run on a
  long stack shows the modal dialog and no longer says "Not Responding".

## [1.6.106] - 2026-07-18
### Changed — **Session load runs off the Qt thread — no more "Not Responding".**
Loading a session lagged the UI (you reported it; Windows shows "Python is not responding" on a longer
one) because `load_session` did its slow work — `tifffile.imread` per derived layer, `pd.read_csv` per
table — on the Qt main thread. The 1.6.81/82 progress bars made that wait *visible* without making it
*shorter*. This is the other half: the read moves to a worker thread while a modal dialog keeps the
window painting.

- **`load_session` is split into a read half and an apply half.** `_read_session_payload` does the
  decode and the CSV reads and touches **no viewer** (structurally — it has no viewer parameter), so it
  is safe to run on a `QThread`. `_apply_session_payload` creates the napari layers and writes the data
  repository, always on the caller's thread — because `viewer.add_*` off the main thread is a crash,
  not a freeze. `load_session` orchestrates the two via `pycat.utils.qt_worker.run_with_progress`.
- **The UI wiring** (`_open_session_loader`, and the quick "restore latest" path) passes
  `use_worker=True`. The worker owns a modal `QProgressDialog`; the old in-dialog progress bar is
  retired so there aren't two bars for one operation. Headless callers and tests default to
  `use_worker=False` (synchronous) and are unaffected — `run_with_progress` also falls back to
  synchronous when there is no running Qt app.
- **`qt_worker.run_with_progress`** (new, `pycat/utils/qt_worker.py`) runs a function on a `QThread` and
  returns its value **on the caller's thread**, re-raising exceptions there so existing `try/except`
  still works. It deliberately refuses a callback/future API so nobody is tempted to create a layer in
  the worker. Two subtle bugs in the pattern it replaces are fixed in it: a fast worker finishing before
  `exec_()` is entered (deadlock), and a signal-to-plain-function running the slot on the worker thread
  (off-main widget touch). Both were headlessly tested.
### Notes
- Headless-tested: the read half takes no viewer and decodes into a payload; the apply half is the only
  half that calls `viewer.add_*`; the synchronous round trip is unchanged; the worker helper's deadlock
  and thread-affinity fixes. **The off-thread feel needs a viewer** — confirm a real multi-file session
  restore no longer says "Not Responding" and the modal progress dialog advances while the window stays
  responsive.
- This also stages `load_session` (was 149 lines, over the complexity ceiling) into per-phase helpers —
  the prerequisite the roadmap called out either way. The same `qt_worker` helper now exists to move the
  per-widget `materialize_stack` freezes off-thread next (the other half of the same fix).

## [1.6.105] - 2026-07-18
### Changed — **The picked-track highlight is a Tracks layer at 2× the base width.**
From the viewer: after zooming to the bead, the picked-track line was still too thick to read the
trajectory's detail. The cause was a unit mismatch — the highlight was a Shapes path whose width is in
**data units**, so it ballooned as the new zoom-to-bead magnified the view, while the base "Bead
Trajectories" layer (a napari Tracks layer) has its width in **screen pixels** and stays constant.

- **The picked track is now a Tracks layer**, the same type as the base, so its `tail_width` is in
  screen pixels and no longer fattens at deep zoom. The width is exactly **2× the base**
  (`_PICKED_TRACK_TAIL_WIDTH = 2 · _BASE_TRACK_TAIL_WIDTH`, both new constants) — bold enough to stand
  out, thin enough to read the detail — which is what the user asked for by eye.
- **Still orange, still a separate overlay.** It colours via a registered flat-orange colormap
  (`#ff8c00`) rather than recolouring the base layer, so a user's own track colouring is never
  clobbered by a pick. `tail_length`/`head_length` span the whole track so it draws fully at any
  frame, including the bead's first frame. Falls back to a thin Shapes path only if `add_tracks` is
  unavailable.
### Notes
- Headless-tested: the picked track is a Tracks layer at 2× the base width, orange, and spans its full
  frame range. **The zoom-stable feel is UI-coupled** — confirm the line reads well at the zoom-to-bead.

## [1.6.104] - 2026-07-18
### Changed — **A VPT plot click now goes to the bead; the pulse is gone.**
From the viewer, on the picked track: the opacity slider oscillated continuously with no visible glow,
the highlight line was too bold to see detail through, and a click should take the stack to the bead's
z-slice and zoom in. Three fixes.

- **A plot click navigates to the bead — on by default.** `_navigate_to_bead` steps to the bead's
  frame, centres on it, and **zooms** so a small window (`_BEAD_ZOOM_WINDOW_PX = 80 px`) around it fills
  the view. Navigation was gated off while the plot-click loop existed; with one `button_press` per
  click (1.6.100) and the `_revealing` re-entrancy guard, the camera move is safe, so going to the bead
  — what the user asked a click to do — is the default now. VPT's now-unused `_follow_enabled` wrapper
  was removed; the generic brushing path keeps its own for the `follow_selection`/double-click case.
- **The pulsing ring was removed.** `_pulse_layer` armed a QTimer that oscillated the ring's
  size/opacity. But the ring is per-frame — present only on the bead's own frame — so scrubbing away
  left nothing to pulse while the opacity slider churned on for nothing. The ring is a static hollow
  marker now (`size=12, opacity=0.9`); the zoom-to-bead navigation is what draws the eye.
- **The picked-track highlight was thinned**, `_PICKED_TRACK_WIDTH_PX` 1.0 → 0.4, so the trace no
  longer obscures the trajectory detail underneath it.
### Notes
- Headless-tested: the pick navigates (steps + centres) and marks the track, the reveal stays
  re-entrant-guarded so navigating cannot loop, the ring is static with no timer armed, and the removed
  symbols are recorded in `_DELIBERATE`. **The zoom-to-bead feel is UI-coupled and needs a viewer** —
  confirm a plot click lands on the bead at a sensible zoom and the thinner line reads well.

## [1.6.103] - 2026-07-18
### Added — **Session auto-restore: a load reopens the analysis method and rebuilds its view.**
Loading a session restored the dataframes into the repository but left an empty panel — the user had
to reopen the method and re-Compute by hand. Now a load lands back at the working state.

- **The active method is recorded on save.** The manifest gains `active_method` (the open analysis
  UI's class name), written by `write_session_outputs`.
- **The loader surfaces it**, and `_on_load` reopens that method via its `_switch_to_*` handler.
  Switching methods **preserves the data repository**, so the reopened method sees the restored data.
  A session saved before this was recorded has no `active_method`; the method is then inferred from a
  signature dataframe (`vpt_tracks` → VPT), so existing sessions restore too.
- **The reopened method rebuilds its view.** `VideoParticleTrackingUI.restore_session_view` rebuilds
  the trajectory + pickable layers and calls `_on_rheology` — the exact handler the **Compute MSD &
  Viscosity** button runs, which reads `vpt_tracks` from the repository — so the MSD/moduli plots come
  back through the one real render path, not a divergent copy. The slow part of VPT (detection +
  linking) is not redone; recomputing the MSD from the restored tracks is seconds.
### Notes
- Headless-tested: the manifest records/surfaces `active_method`, back-compat returns None (inferred
  from data), the method registry wires VPT correctly, and the restore hook exists. **The end-to-end
  reopen → rebuild → plots is UI-coupled and needs a viewer** — this is the part to confirm: load the
  session and check the VPT method reopens with its tracks clickable and its plots drawn.
- Parameters return at their defaults (frame interval auto-fills from the source metadata); a user who
  needs the session's exact bead radius/temperature sets them and re-Computes. Restoring the exact
  recorded parameters is a later refinement.
- Only VPT has a `restore_session_view` so far; other methods reopen (data preserved) and show a
  "reopen to rebuild" toast until they gain the same hook — additive, method by method.

