Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ are in [docs/plan.md](docs/plan.md) §12. Upgrading from v0.3: see the

## Unreleased

- **Notebook: your channels, labels first then tag per unit (Stage 1 PR 15, draft).** After the axis
roles are confirmed, *Which labels did you image?* lists what was stained (prefilled only from
header names that are valid IDs as they are and not generic such as `Ch1`, `C0`, `AF488`; near
spellings are grouped for the user to decide, never merged; a case collision is refused at once),
and *Tag each channel* shows one row per unit with a safe thumbnail, the quoted header name and a
dropdown of the labels per channel, plus a per-unit image type with nothing preselected. Labels
used in only some units say so (`A_SCOPE_INCOMPLETE`). Every answer is saved to the draft, which is
now version 3 (a unit's `channels` may hold `null` for not tagged, never `""`: every position needs a declared label, e.g. `unused`;
`image_type` may be `channels`; versions 1 and 2 are refused); contract §18.
- **Contract: the final mask check is as late as practicable, not a lock (#222, docs only).** Source masks are checked right before the manifest replace and ordinary concurrent edits are detected; the files are not locked, so a change between that check and the replace is not detected, and the manifest binds the bytes as they were at the final check (owner decision of 8 November 2026: documented, not snapshotted). `docs/contract.md` §9.4.
- **Notebook Step 1 cards: names, IDs, pairing and references (Stage 1 PR 14, draft).** After the
labels are confirmed, *Names for your experiment* proposes the folder names as IDs (only where
Expand Down
8 changes: 4 additions & 4 deletions docs/contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -665,15 +665,15 @@ One JSON object, UTF-8, two-space indent, sorted keys, LF, a final newline, no d
| Key | Value |
|---|---|
| `format` | `"morphobricks-description-draft"` |
| `version` | `2` (an integer; version 1, written before `pairing` existed, is refused as an unknown version; any other value is refused as an unknown version, never read as a newer or older one) |
| `version` | `3` (an integer; versions 1 and 2 are refused as unknown versions: version 2 had no tagging states; any other value is refused as an unknown version, never read as a newer or older one) |
| `base_sha256` | The lower-case hex SHA-256 of the exact bytes of the published `morphobricks.toml` the draft was derived from, or `"absent"`. Missing or any other value: `R_DRAFT_BASE_MISSING` |
| `spec_commit` | The 40-digit MorphoBricks-Spec commit the rules are pinned to (`SPEC_COMMIT`, equal to `tests/spec_fixtures/PIN.toml`) |
| `conditions` | Condition **source folder** → object with only the keys that were answered, from `id`, `display_name`, `reference` (the source folder of the condition it is compared with, or `""` when the user answered that it is compared with nothing; absent means not answered), `target`, `treatment_key` |
| `technical_repeats` | Technical-repeat source folder (`single` for none) → its confirmed ID |
| `repeats` | The pairing label of the draft's repeat (the folder name, or the legacy `repeat_group`) → object with only the answered keys, from `id`, `confirmed_on`, `display_name` |
| `pairing` | Condition source folder → source repeat folder → the pairing label of the repeat the user paired that folder with (only folders not in the repeat of their own name; applied to the draft first, so the `repeats` keys are the paired labels; refused with `pairing_after_confirmation` once a repeat is named or confirmed, `unknown_key` for a folder the draft lacks). A reference to the condition itself or one that closes a loop is refused when saved (`E_REFERENCE_SELF`, `E_REFERENCE_CYCLE`). |
| `units` | `condition folder` → `source repeat folder` → `technical-repeat folder` → `{channels, image_type?, display_name?}`; `channels` lists the channel ID of C0, C1, ... in order |
| `channels` | Channel ID → `{display_name?, header_names?}` (the aliases the user confirmed) |
| `units` | `condition folder` → `source repeat folder` → `technical-repeat folder` → `{channels, image_type?, display_name?}`; `channels` lists, for C0, C1, ... in order, a channel ID (every position needs a label the user declared, for example `unused`; an empty string is refused) or `null` (not tagged yet); `image_type` is `"rgb"` or `"channels"` (an ordinary channel image; absent means not answered), any other value is refused |
| `channels` | Channel ID → `{display_name?, header_names?}` (the label list of the notebook's *Your channels* screen; `header_names` are the raw header names of every cell the user tagged with that label, recomputed at each save: the tag is the alias confirmation) |
| `masks` | Mask ID → `{source_type, representation, source_channels?, display_name?}` |

Rules.
Expand All @@ -682,5 +682,5 @@ Rules.
- **Saving** (`save_draft`) is atomic (temporary file, one replace) and under the output folder's lock, after each answer. The answers are applied to the draft and the exact bytes are read back with the strict reader first, so an answer that would be refused on resume (a `spec_commit` other than the pinned one, `spec_commit_mismatch`; an ID the reader would refuse, a folder the draft does not have, a date that is not a real day) is refused when saved and nothing is written. An incomplete draft can be saved. The file is replaced, not merged. A symbolic link at the draft's path is refused and never replaced.
- **Reading** is strict and never repairs: bytes that are not UTF-8 JSON, a byte-order mark, a duplicate key, `NaN`, another `format`, an unknown `version`, an unknown key at any level, a missing key, a value of the wrong type or a date that is not a real `YYYY-MM-DD` day are `DraftFileError` (`code`: `malformed`, `unknown_version`, `unknown_key`, `R_DRAFT_BASE_MISSING`, `R_FILE_SYMLINK`). Reading accepts any 40-digit `spec_commit`; whether it is the current one is decided at resume and save, below.
- **Resume** (`load_draft`): the file's `spec_commit` must equal the pinned `SPEC_COMMIT`, else `DraftFileError` with code `spec_commit_mismatch` (owner decision, 2026-11-08): the confirmations in the file were given under other rules, so none is carried forward. The message names both commits, says the published `morphobricks.toml` is untouched and says to start a fresh draft from the folders. Library code never rewrites the stored commit and never deletes or replaces the stale draft; the caller derives a fresh draft and saves it, which replaces the file. `publish_draft` resumes first, so it refuses the same way and writes nothing. Then the file's `base_sha256` must equal the SHA-256 of the file published now (or `absent` if there is none), else `R_PUBLISHED_FILE_CHANGED` (`PublicationRefused`); the draft is never merged with the new file, it is derived again from it. An answer keyed by a folder or repeat the draft no longer has is `DraftError` (`unknown_key`), never moved to another folder.
- **Draft to description** (`description_from_draft(draft, answers)`, pure): refuses with `DraftIncomplete`, listing **every** missing confirmation once (`id_pending`, `repeat_not_confirmed`, `confirmed_on_missing`, `control_unresolved`, `legacy_folder_missing`, `unit_channels_missing` for a unit with no channel list, `channel_undeclared` for a channel a unit names that is not declared), otherwise returns the `Description`. Nothing is confirmed for the user. `used_ids` is the draft's retired IDs plus every ID confirmed now. The description is then validated by the strict reader at publication; the function does not repeat the reader's rules.
- **Draft to description** (`description_from_draft(draft, answers)`, pure): refuses with `DraftIncomplete`, listing **every** missing confirmation once (`id_pending`, `repeat_not_confirmed`, `confirmed_on_missing`, `control_unresolved`, `legacy_folder_missing`, `unit_channels_missing` for a unit with no channel list, `unit_channel_untagged` for a position still `null`, `channel_undeclared` for a channel a unit names that is not declared), otherwise returns the `Description`. Nothing is confirmed for the user. `used_ids` is the draft's retired IDs plus every ID confirmed now. The description is then validated by the strict reader at publication; the function does not repeat the reader's rules.
- **Publication** (`publish_draft`): under one hold of the lock the draft is resumed, the description built, published with all the checks of `publish_description` (symbolic link, base, transition, strict reader on the exact bytes), and then the draft file is deleted. Any refusal writes nothing: the draft and the published file stay byte for byte as they were. If the draft cannot be deleted after a successful publication, the error is raised and the publication stands; the leftover draft records a base that no longer matches and is refused on resume.
1 change: 1 addition & 0 deletions notebooks/MorphoBricks_Preprocessing.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@
" notebook.welcome(),\n",
" notebook.folder_step(),\n",
" notebook.axis_step(),\n",
" notebook.channels_step(),\n",
" notebook.run_step(),\n",
" notebook.results_step(),\n",
" ],\n",
Expand Down
9 changes: 9 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,12 @@ baseline = "tools/code_rules_baseline.json"
"morphobricks_preprocessing.mask_binding" = "io"
"morphobricks_preprocessing.mask_record" = "io"
"morphobricks_preprocessing.notebook_axes" = "ui"
"morphobricks_preprocessing.notebook_channel_thumbs" = "ui"
"morphobricks_preprocessing.notebook_channels" = "ui"
"morphobricks_preprocessing.notebook_channels_grid" = "ui"
"morphobricks_preprocessing.notebook_channels_labels" = "ui"
"morphobricks_preprocessing.notebook_channels_type" = "ui"
"morphobricks_preprocessing.notebook_channels_view" = "ui"
"morphobricks_preprocessing.notebook_axis_preview" = "ui"
"morphobricks_preprocessing.notebook_axis_step" = "ui"
"morphobricks_preprocessing.notebook_description" = "ui"
Expand Down Expand Up @@ -194,6 +200,9 @@ baseline = "tools/code_rules_baseline.json"
"morphobricks_preprocessing.design_derive" = "io"
"morphobricks_preprocessing.description_identity" = "pure"
"morphobricks_preprocessing.draft_ids" = "pure"
"morphobricks_preprocessing.channel_labels" = "pure"
"morphobricks_preprocessing.channel_units" = "pure"
"morphobricks_preprocessing.channel_answers" = "pure"
"morphobricks_preprocessing.draft" = "io"
"morphobricks_preprocessing.draft_answers" = "io"
"morphobricks_preprocessing.draft_format" = "io"
Expand Down
127 changes: 127 additions & 0 deletions src/morphobricks_preprocessing/channel_answers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"""Changes to the draft answers made by the *Your channels* cards, each returning new answers.

The label list (``channels``), the tags and the image type of each unit (``units``) are all in the
draft (:mod:`.draft_answers`); these functions change one slice and keep the rest exactly:

- :func:`with_labels` replaces the label list (the colour tokens ``R``, ``G``, ``B`` of declared
colour units are kept) and takes back every tag of a label that is no longer in it;
- :func:`with_tags` and :func:`with_type` change one unit; a colour unit is tagged ``R``, ``G``,
``B`` and nothing else, an ordinary one starts untagged;
- :func:`synced` recomputes what is derived from the tags: the header names each label is
evidence for (the tag is the user's confirmation of the alias) and the declared colour tokens.

Nothing is guessed: a position is tagged only by a tag the user gave.
"""

from __future__ import annotations

from collections.abc import Collection, Mapping, Sequence
from dataclasses import replace

from .channel_labels import RGB_IDS, UnitLayout, aliases
from .draft_answers import ChannelAnswer, DraftAnswers, UnitAnswer, UnitKey


def label_ids(answers: DraftAnswers) -> list[str]:
"""Return the declared labels, sorted: the channels that are not a colour token."""
return sorted(k for k in answers.channels if k not in RGB_IDS)


def _unit(answers: DraftAnswers, layout: UnitLayout) -> UnitAnswer:
saved = answers.units.get(layout.key)
if saved is not None and len(saved.channels) == layout.channels:
return saved
return UnitAnswer(
(None,) * layout.channels,
None if saved is None else saved.image_type,
None if saved is None else saved.display_name,
)


def _store(answers: DraftAnswers, key: UnitKey, unit: UnitAnswer) -> DraftAnswers:
units = dict(answers.units)
if all(t is None for t in unit.channels) and unit.image_type is None and not unit.display_name:
units.pop(key, None)
else:
units[key] = unit
return replace(answers, units=units)


def with_tags(
answers: DraftAnswers, layout: UnitLayout, tags: Sequence[str | None]
) -> DraftAnswers:
"""Return the answers with the tags of one unit set (one entry per C index)."""
if len(tags) != layout.channels:
raise ValueError(f"{layout.key} has {layout.channels} channels, not {len(tags)} tags")
return _store(answers, layout.key, replace(_unit(answers, layout), channels=tuple(tags)))


def with_type(answers: DraftAnswers, layout: UnitLayout, image_type: str | None) -> DraftAnswers:
"""Return the answers with one unit's image type set.

A colour unit's channels are ``R``, ``G``, ``B``; going back to ordinary channels (or to no
answer) clears them, since nothing says which label each position then is.
"""
unit = _unit(answers, layout)
if image_type == "rgb":
if layout.channels != len(RGB_IDS):
raise ValueError(f"a colour image has 3 channels, {layout.key} has {layout.channels}")
tags: tuple[str | None, ...] = RGB_IDS
elif unit.image_type == "rgb":
tags = (None,) * layout.channels
else:
tags = tuple(unit.channels)
return _store(answers, layout.key, replace(unit, channels=tags, image_type=image_type))


def with_labels(answers: DraftAnswers, labels: Mapping[str, str | None]) -> DraftAnswers:
"""Return the answers with exactly these labels (ID -> shown-as name or None).

A tag of a label that is gone becomes untagged (never moved to another label).
The colour tokens of declared colour units stay.
"""
kept = {k: v for k, v in answers.channels.items() if k in RGB_IDS}
channels = {
label: replace(answers.channels.get(label, ChannelAnswer()), display_name=name or None)
for label, name in labels.items()
}
units = {}
for key, unit in answers.units.items():
if unit.image_type == "rgb":
units[key] = unit
continue
tags = tuple(t if t in labels else None for t in unit.channels)
units[key] = replace(unit, channels=tags)
return replace(answers, channels={**kept, **channels}, units=units)


def untagged_by(answers: DraftAnswers, labels: Collection[str]) -> int:
"""Return how many saved tags name a label that is not in ``labels``."""
return sum(
1
for unit in answers.units.values()
if unit.image_type != "rgb"
for t in unit.channels
if t and t not in labels
)


def synced(answers: DraftAnswers, layouts: Sequence[UnitLayout]) -> DraftAnswers:
"""Return the answers with the derived parts recomputed from the saved tags.

Each label's ``header_names`` become the raw header names of every cell tagged with it (None
when none has a name), and ``R``, ``G``, ``B`` are declared exactly when some unit is a
colour unit.
"""
found = aliases(layouts, answers.units)
channels = {
k: replace(v, header_names=found.get(k))
for k, v in answers.channels.items()
if k not in RGB_IDS
}
if any(u.image_type == "rgb" for u in answers.units.values()):
channels.update({token: ChannelAnswer() for token in RGB_IDS})
return replace(answers, channels=channels)


__all__ = ["label_ids", "synced", "untagged_by", "with_labels", "with_tags", "with_type"]
Loading
Loading