diff --git a/CHANGELOG.md b/CHANGELOG.md index d1fb4398..be2c76f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ are in [docs/plan.md](docs/plan.md) §12. Upgrading from v0.3: see the ## Unreleased +- **Spec pin moved to MorphoBricks-Spec `745259b` (Stage 1, PR A).** The vendored fixtures, + `experiment-v1.md` and the reference validator are the Spec's at that commit (new fixtures + `i131`-`i134`; 36 valid, 134 invalid, 12 transition). The reader now applies the condition + `single` rule (a condition ID is `single` if and only if its `source_folder` is `single`, and + such a condition is the only one): `E_SINGLE_MISUSE`, phase 4. A key written twice inside an + inline table was already `E_DUPLICATE_KEY`. Reader and reference validator agree on every + fixture and mutation. Not used by any run, notebook or CLI command yet. - **Experiment-description reader, phases 4-5 and warnings (Stage 1, PR 3).** New modules `description_identity`, `description_semantics`, `description_warnings` and `description_walk`; `description_read.read_description_bytes(data: bytes)` runs all five diff --git a/src/morphobricks_preprocessing/description_identity.py b/src/morphobricks_preprocessing/description_identity.py index 99f18a0d..60403333 100644 --- a/src/morphobricks_preprocessing/description_identity.py +++ b/src/morphobricks_preprocessing/description_identity.py @@ -81,10 +81,18 @@ def collisions(self) -> None: def _conditions(doc: Table, ids: Identities) -> None: + """Check condition IDs and folders, and the ``single`` rule of conditions.""" + count = len(doc["conditions"]) for identifier, table in doc["conditions"].items(): path = key_path("conditions", identifier) ids.label_id(identifier, path, "conditions") ids.label_id(table["source_folder"], f"{path}.source_folder", "condition_folders") + folder = table["source_folder"] + if (identifier == SINGLE) != (folder == SINGLE): # `single` iff no condition folder level + ids.out.add(Code.E_SINGLE_MISUSE, f"{path}.source_folder", folder) + elif folder == SINGLE and count > 1: + message = "single is not the only condition" + ids.out.add(Code.E_SINGLE_MISUSE, f"{path}.source_folder", message) if "reference" in table: # a reference is checked for NFC only, then resolved (phase 5) ids.nfc(table["reference"], f"{path}.reference") diff --git a/tests/spec_fixtures/PIN.toml b/tests/spec_fixtures/PIN.toml index d282d156..df928d16 100644 --- a/tests/spec_fixtures/PIN.toml +++ b/tests/spec_fixtures/PIN.toml @@ -1,13 +1,13 @@ # The MorphoBricks-Spec commit these files are copied from (conformance plan section 1). # Updating the pin is a reviewed change: see README.md in this folder. repository = "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/CellMigrationLab/MorphoBricks-Spec" -commit = "8922a359ec261148a23a4b6e740a1bdb69e9e318" +commit = "745259b72d4252f3ddaa563302d3343a1fa2ed49" # `git rev-parse :fixtures`, the git tree-object ID of the fixtures/ folder. The # vendored folder must hash to it, so one changed, added or missing byte fails a test. -fixtures_tree = "6d114d8028acc11cce1fba65b2f4f0d8314076d5" +fixtures_tree = "d26daf758c914bdb0f6bfde1bba278d99cc33bb8" # `git rev-parse :`, the git blob ID of each other vendored file. [files] -"docs/experiment-v1.md" = "3e5bd59edfa9e9b2deeb0b480d03da8ed57bb9f2" -"tools/audit/refval.py" = "bff88079732a63fe5ba11dd9df5a7887348d0587" +"docs/experiment-v1.md" = "6de05a197caf7b57c6ce0409f9593a9c56d23b7c" +"tools/audit/refval.py" = "2ce3a512fecafeb954aff32308f925ce23b8cd59" diff --git a/tests/spec_fixtures/docs/experiment-v1.md b/tests/spec_fixtures/docs/experiment-v1.md index 3e5bd59e..6de05a19 100644 --- a/tests/spec_fixtures/docs/experiment-v1.md +++ b/tests/spec_fixtures/docs/experiment-v1.md @@ -28,6 +28,8 @@ requests, normalization, statistics, histogram or model settings. Those stay in v1 analyses 2-D planes only. A Z stack is projected by a recorded preprocessing step (preprocessing #176) before it is described; the file has no Z, axes or shared-plane field. +Time-lapses (T > 1) are allowed: the file has no T field, and the homogeneity of an imaging unit +(§8, `R_UNIT_NOT_HOMOGENEOUS`) compares what each axis means, never the number of time points. ## 2. File, encoding and parsing @@ -58,8 +60,8 @@ v1 analyses 2-D planes only. A Z stack is projected by a recorded preprocessing | Identity | Where | Canonical or source | Rule set | |---|---|---|---| | Condition ID | key of `conditions` | Canonical (user-confirmed; proposed = the source folder label when allowed, §4.6a) | Label | -| Condition source folder | `conditions..source_folder` | Source (condition folder label; not an identity key) | Label | -| Canonical repeat ID | key of `repeats` | Canonical (user-confirmed; default the source folder label) | Label | +| Condition source folder | `conditions..source_folder` | Source (condition folder label, or `single`; not an identity key) | Label | +| Canonical repeat ID | key of `repeats` | Canonical (user-confirmed; proposed = the source folder label when allowed, §4.6a) | Label | | Source repeat | `source_repeat` | Source (biological-repeat folder label) | Label | | Technical repeat ID | key of `technical_repeats` | Canonical (user-confirmed; proposed = the source folder label when allowed, §4.6a; or `single`) | Label | | Technical-repeat source folder | `technical_repeats..source_folder` | Source (technical-repeat folder label, or `single`; not an identity key) | Label | @@ -100,7 +102,10 @@ v1 analyses 2-D planes only. A Z stack is projected by a recorded preprocessing repeat of its member; a member with `source_repeat = "single"` MUST be the only member of its condition in the file. A technical repeat ID is `single` if and only if its `source_folder` is `single` (`single` stands for "no technical-repeat folder level"; `E_SINGLE_MISUSE`; - preprocessing contract §1). + preprocessing contract §1). Conditions follow the same rule: a condition ID is `single` if and + only if its `source_folder` is `single` (`single` stands for "no condition folder level", an + experiment folder that is itself the leaf), and a condition whose `source_folder` is `single` + MUST be the only condition in the file (`E_SINGLE_MISUSE`). ### 3.3 Normalization, uniqueness and case @@ -156,8 +161,8 @@ v1 analyses 2-D planes only. A Z stack is projected by a recorded preprocessing FOV key, `condition` and `technical_repeat` stay the **source folder labels** (`conditions..source_folder`, `technical_repeats..source_folder`); a consumer MUST NOT replace them by the canonical IDs in FOV, patch or object identities, fitted-scaling keys, - caches or exports. The canonical IDs are **added** next to the key (proposed field names - `condition_id` and `technical_repeat_id`; the consumer contracts fix the spelling in Stage 1). + caches or exports. The canonical IDs are **added** next to the key, beside `repeat_group`, under + the names `condition_id` and `technical_repeat_id`, which Stage 1 fixes for every consumer. Each is a function of the FOV key (one folder, one ID, §4.6a), so adding them creates no new identity. Everything that names a condition by identity (`reference`, families, pairing, contrasts, `used_ids`) uses the canonical condition ID, and a consumer MUST NOT group, pair or @@ -186,7 +191,7 @@ real calendar day (`E_DATE_FORMAT`); a TOML date literal is `E_TYPE`. "Non-empty | Key | Type | Req | Rule | |---|---|---|---| -| `source_folder` | text | yes | Exact condition folder label as read from disk (Label rules, NFC); a source value, **not an identity key**; not restricted by the reserved or forbidden names (§4.6a) | +| `source_folder` | text | yes | Exact condition folder label as read from disk, or `single` when the experiment has no condition folder level (Label rules, NFC); a source value, **not an identity key**; not restricted by the reserved or forbidden names (§4.6a) | | `display_name` | non-empty text | no | Display only (§4.8); MAY be the folder label | | `reference` | text | no | One condition ID: the primary reference (§5) | | `target` | non-empty text | no | Descriptive plain string, e.g. `SRC` (§4.2a) | @@ -375,8 +380,12 @@ stays in the brick that owns it. renamed or replaced on disk under an existing ID is a removed folder plus a new pending one (§7), and giving the existing ID the new `source_folder` is a reassignment under (c). - A mistyped ID is fixed only by an explicit, recorded re-confirmation: a user action that - writes a new file and records old ID, new ID, date and both file SHA-256 in the run manifest. - Products keyed on that ID are invalidated. Nothing about it is stored in the file. + writes a new file and records old ID, new ID, date and both file SHA-256. The record is made + at publication, which happens outside a run: preprocessing appends it, under the output + folder's publication lock (§10), to the append-only file `morphobricks.records.jsonl` next to + `morphobricks.toml`, and copies the records unchanged into the manifest of the next run. The + records file is never rewritten or truncated, and nothing about a re-confirmation is stored in + `morphobricks.toml`. Products keyed on that ID are invalidated. - **A case-only correction needs a new ID (AQ2).** `ACTIN` cannot be corrected to `Actin`: the retired spelling stays in `used_ids` and the new one collides with it (`E_ID_CASE_COLLISION`, §4.9). The correction is a new, non-colliding ID (for example @@ -414,11 +423,20 @@ discovered under is recorded separately in the required field `source_folder`. is allowed, and the user confirms it or types another; the user MAY also set `display_name` to the folder label. A proposal is never adopted unconfirmed, and no ID is repaired or derived by a reader (§3.3). +- **Repeat IDs follow the same rule.** For a new biological-repeat folder the proposed repeat ID is + the folder label if it is allowed in the same sense (Label rules, not an exact forbidden name, + no exact or case-folded collision with a declared or `used_ids` repeat ID). If it is not + allowed (for example the folder `z`), preprocessing makes **no proposal**: it never repairs or + derives an ID from the label (§3.3), and the user names the repeat ID. The reader validates what + the user typed like any ID; `source_repeat` keeps the folder label. - **Existing ID, other folder.** Giving an existing condition or technical repeat ID another `source_folder` is a reassignment (§4.6): `R_CONFIRMED_ID_CHANGED` without the recorded user confirmation. A new folder next to the existing ones is a new pending member or unit (§10). - **`single`.** The technical repeat `single` has `source_folder = "single"`: the label denotes - "no technical-repeat folder level", not a folder (§3.2). + "no technical-repeat folder level", not a folder (§3.2). The same holds for a condition: the + condition `single` has `source_folder = "single"`, which denotes "no condition folder level" (an + experiment folder that is itself the leaf, with the member `source_repeat = "single"` and the + technical repeat `single`), and it is the only condition of the file (§3.2). - A folder label that breaks the Label rules (for example `NA`) is still refused at discovery as today; this decision adds no new code for it. @@ -545,7 +563,7 @@ for NFC only (phase 4) before it is resolved (phase 5). | 4 | `E_ID_RESERVED` | Name on the reserved list of §3.2 (channel and mask IDs), or a mask ID with an `in` part | | 4 | `E_ID_NOT_NFC` | An identity (key, reference, source repeat, `source_folder`, unit `channels` or `source_channels` entry, `used_ids` entry) is not Unicode NFC; never normalized | | 4 | `E_ID_CASE_COLLISION` | Two identities of one scope equal ignoring case (`used_ids` entries share the scope of their kind; source folders have their own scopes, §3.3) | -| 4 | `E_SINGLE_MISUSE` | `single` in another case or not alone, or a technical repeat ID `single` whose `source_folder` is not `single` (or the reverse) | +| 4 | `E_SINGLE_MISUSE` | `single` in another case or not alone, or a technical repeat ID or condition ID `single` whose `source_folder` is not `single` (or the reverse) | | 5 Semantics | `E_UNKNOWN_CONDITION` | Member key is not a declared condition ID | | 5 | `E_UNKNOWN_CHANNEL` | Unit or `source_channels` names an undeclared channel ID | | 5 | `E_DUPLICATE_CHANNEL_IN_UNIT` | A channel twice in one unit | diff --git a/tests/spec_fixtures/fixtures/README.md b/tests/spec_fixtures/fixtures/README.md index 32503b99..657e854b 100644 --- a/tests/spec_fixtures/fixtures/README.md +++ b/tests/spec_fixtures/fixtures/README.md @@ -63,9 +63,10 @@ round of owner decisions and again after the fourth (folder labels, `source_fold The matrix audit ([`docs/audits/matrix-audit-2026-11-07.md`](../docs/audits/matrix-audit-2026-11-07.md)) adds `v35`, `v36` and `i128`-`i130` for structures the catalogue lacked (a flat experiment, a biological-repeat folder named like a forbidden key, `single` rules for condition folders and -members). Its audit-only validator, [`tools/audit/refval.py`](../tools/audit/refval.py) +members). The owner decisions of 7 November 2026 (ADR 0001, SQ6) add `i131`-`i133` (the +`single` rules for conditions) and `i134` (a duplicate key inside an inline table). Its audit-only validator, [`tools/audit/refval.py`](../tools/audit/refval.py) (`python -I tools/audit/refval.py fixtures .`), accepts 36 of 36 valid files with exactly the -listed warnings, refuses 130 of 130 invalid files with exactly the listed code set from the listed +listed warnings, refuses 134 of 134 invalid files with exactly the listed code set from the listed phase, gives the listed `R_*` set for 12 of 12 transitions, and round-trips every valid and transition file through the canonical writer. It is an audit tool, not a reader: the conformance plan's single reference reader stays preprocessing. @@ -133,7 +134,7 @@ This is a check of the fixtures against the text, not a conformance claim for an | Topic | Fixtures | |---|---| -| Duplicate keys | `i03`-`i06` | +| Duplicate keys | `i03`-`i06`, `i134` (inline table) | | Case collisions | `i48`-`i52`, `i68` (no folding when resolving), `i95` | | NFC (refused, never normalized) | `v23`, `i78`, `i83`-`i85`; P21 | | Display names | `v16`, `v24`, `i53`, `i76`, `i77`, `i86`-`i88` | @@ -158,7 +159,7 @@ This is a check of the fixtures against the text, not a conformance claim for an | Reserved names (one list for channel and mask IDs) | `i59`, `i61`, `i107`-`i109`, `v32` | | Forbidden names apply to ID keys | `i113`-`i115` | | Folder labels never restricted: condition and technical-repeat IDs separate from `source_folder` | `v33`, `v34`, `i113`, `i115`, `i120`-`i127`, `t10`-`t12`; P32, P33 | -| One folder, one ID; `single` iff no folder level | `i122`-`i125` | +| One folder, one ID; `single` iff no folder level (technical repeats: `i125`; conditions: `i131`-`i133`) | `i122`-`i125`, `i131`-`i133` | | Folder change for an existing ID is a reassignment | `t10`-`t12`; P33 | | One code per broken rule; NFC of channel references | `i116`-`i118` | | FOV key keeps the source label, `repeat_group` added | `v03`, `v30`; P25 | @@ -251,8 +252,8 @@ This is a check of the fixtures against the text, not a conformance claim for an | [`v32_reserved_name_near_misses`](valid/v32_reserved_name_near_misses.toml) | Owner decision AQ4 | pass | yes | Names next to the reserved list (experiment-v1 3.2) are valid: channels c1, t0, z2 and label, masks Patches and Labels. Only the exact names, compared case-folded, are reserved (t, z0, label_id, patch_id, ...). | | [`v33_condition_folder_on_forbidden_list`](valid/v33_condition_folder_on_forbidden_list.toml) | Owner decision FR1 | pass | yes | The condition folder on disk is `control`, a forbidden name (experiment-v1 4.7), so it cannot be the ID. It is recorded as `source_folder` (never restricted) and the user confirmed the ID `ctrl`, with `display_name` `control`. DrugA references `ctrl`, the ID. The derived experiment_design.csv still has `control` in the `condition` column (experiment-v1 11). | | [`v34_technical_repeat_folder_on_forbidden_list`](valid/v34_technical_repeat_folder_on_forbidden_list.toml) | Owner decision FR1 | pass | yes | Repeat rep1 has two technical-repeat folders, `files` and `overrides`, both forbidden names (experiment-v1 4.7). They are the `source_folder` values; the confirmed IDs are `acq1` and `acq2`, and the same ID carries the same folder in both members. rep2 has no technical-repeat folder level (`single`). Homogeneous. | -| [`v35_flat_experiment`](valid/v35_flat_experiment.toml) | Matrix audit 2026-11-07, case F01 | pass | yes | The experiment folder is itself the leaf: condition, repeat and technical repeat are all `single`. Valid as written; whether `single` is a condition `source_folder` (4.2: "the condition folder label as read from disk") is open (matrix audit MS-1). | -| [`v36_repeat_folder_forbidden_name`](valid/v36_repeat_folder_forbidden_name.toml) | Matrix audit 2026-11-07, case F16 | pass | yes | Biological-repeat folders `z` paired into repeat `R1`; `source_repeat` is never restricted. The default ID `z` is refused (`i130`); 4.6a has no "propose another ID" rule for repeats (matrix audit MS-2). | +| [`v35_flat_experiment`](valid/v35_flat_experiment.toml) | Matrix audit 2026-11-07, case F01 | pass | yes | The experiment folder is itself the leaf: condition, repeat and technical repeat are all `single`. The condition `single` has `source_folder = "single"` ("no condition folder level", 4.2, 3.2) and is the only condition (owner decision SQ6, matrix audit MS-1). | +| [`v36_repeat_folder_forbidden_name`](valid/v36_repeat_folder_forbidden_name.toml) | Matrix audit 2026-11-07, case F16 | pass | yes | Biological-repeat folders `z` paired into repeat `R1`; `source_repeat` is never restricted. The folder label `z` is not an allowed repeat ID (`i130`), so no ID is proposed and the user names `R1` (4.6a; owner decision SQ6, matrix audit MS-2). | ## Transition fixtures (12) @@ -273,7 +274,7 @@ Valid alone; checked against the named published predecessor (refused with the l | [`t11_technical_repeat_id_changed_same_folder`](transitions/t11_technical_repeat_id_changed_same_folder.toml) | `valid/v34` | R_CONFIRMED_ID_CHANGED (against predecessor valid/v34, without a recorded re-confirmation) | The folder `files` was confirmed as technical repeat `acq1` in v34 and now carries the ID `acqA` in both members. A confirmed technical repeat ID never changes in place (experiment-v1 4.6, 4.6a). `acq1` stays listed in used_ids. Valid alone. | | [`t12_condition_folder_reassigned`](transitions/t12_condition_folder_reassigned.toml) | `valid/v33` | R_CONFIRMED_ID_CHANGED (against predecessor valid/v33, without a recorded confirmation) | The ID `ctrl` held the folder `control` in v33 and now holds the folder `ctl` (a folder renamed on disk and attached to the same ID without a recorded confirmation). A folder change for an existing ID is a reassignment, so a changed entity (experiment-v1 4.6, 7). Valid alone. | -## Invalid fixtures (130) +## Invalid fixtures (134) | Fixture | Source | Expected code(s) | Phase | Case | |---|---|---|---|---| @@ -406,4 +407,8 @@ Valid alone; checked against the named published predecessor (refused with the l | [`i127_technical_repeat_id_not_in_used_ids`](invalid/i127_technical_repeat_id_not_in_used_ids.toml) | Owner decision FR1 | `E_USED_ID_MISSING` | 5 | The declared technical repeat ID `single` is not listed in `used_ids.technical_repeats` (experiment-v1 4.9). | | [`i128_condition_folder_single_wrong_case`](invalid/i128_condition_folder_single_wrong_case.toml) | Matrix audit 2026-11-07, case F18 | `E_SINGLE_MISUSE` | 4 | A condition `source_folder` `Single` (source folders included in 3.2). | | [`i129_source_repeat_single_not_alone`](invalid/i129_source_repeat_single_not_alone.toml) | Matrix audit 2026-11-07 | `E_SINGLE_MISUSE` | 4 | CTRL has the member `single` and the member `rep2`. | -| [`i130_repeat_id_from_forbidden_folder`](invalid/i130_repeat_id_from_forbidden_folder.toml) | Matrix audit 2026-11-07, case F16 | `E_FORBIDDEN_FIELD` | 3 | Repeat folders `z` kept as the repeat ID `z`. | +| [`i130_repeat_id_from_forbidden_folder`](invalid/i130_repeat_id_from_forbidden_folder.toml) | Matrix audit 2026-11-07, case F16 | `E_FORBIDDEN_FIELD` | 3 | Repeat folders `z` kept as the repeat ID `z`; no ID is proposed for such a folder (4.6a), the user names it (`v36`). | +| [`i131_condition_id_single_with_real_folder`](invalid/i131_condition_id_single_with_real_folder.toml) | Owner decision SQ6, matrix audit MS-1 | `E_SINGLE_MISUSE` | 4 | The condition ID `single` names the folder `CTRL`: `single` is the ID of `source_folder = "single"` only (experiment-v1 3.2). | +| [`i132_condition_single_folder_other_id`](invalid/i132_condition_single_folder_other_id.toml) | Owner decision SQ6, matrix audit MS-1 | `E_SINGLE_MISUSE` | 4 | The condition `flat` has `source_folder = "single"`: a condition ID is `single` if and only if its folder is `single` (experiment-v1 3.2). | +| [`i133_condition_single_not_alone`](invalid/i133_condition_single_not_alone.toml) | Owner decision SQ6, matrix audit MS-1 | `E_SINGLE_MISUSE` | 4 | The condition `single` (no condition folder level) next to the condition DrugA: it must be the only condition (experiment-v1 3.2). | +| [`i134_duplicate_key_inline_table`](invalid/i134_duplicate_key_inline_table.toml) | Stage 1 design PR 0, experiment-v1 2.2 | `E_DUPLICATE_KEY` | 1 | `display_name` written twice inside the inline table of the channel DNA: a key declared twice in any spelling, inline tables included, is `E_DUPLICATE_KEY`, not `E_TOML_SYNTAX` (experiment-v1 2.2). | diff --git a/tests/spec_fixtures/fixtures/invalid/i130_repeat_id_from_forbidden_folder.toml b/tests/spec_fixtures/fixtures/invalid/i130_repeat_id_from_forbidden_folder.toml index ae788773..522f6674 100644 --- a/tests/spec_fixtures/fixtures/invalid/i130_repeat_id_from_forbidden_folder.toml +++ b/tests/spec_fixtures/fixtures/invalid/i130_repeat_id_from_forbidden_folder.toml @@ -1,7 +1,7 @@ # Fixture i130_repeat_id_from_forbidden_folder # Expected: E_FORBIDDEN_FIELD # Source: Matrix audit 2026-11-07 (case F16) -# The biological-repeat folders `z` kept as the default repeat ID `z`: a forbidden name as an ID key (4.7). The folders are describable under another repeat ID (v36). +# The biological-repeat folders `z` kept as the default repeat ID `z`: a forbidden name as an ID key (4.7). No proposal is made for such a folder (4.6a): the user names the repeat ID, and the folders are describable under another ID (v36). schema = "morphobricks-experiment" schema_version = 1 diff --git a/tests/spec_fixtures/fixtures/invalid/i131_condition_id_single_with_real_folder.toml b/tests/spec_fixtures/fixtures/invalid/i131_condition_id_single_with_real_folder.toml new file mode 100644 index 00000000..23708fd1 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i131_condition_id_single_with_real_folder.toml @@ -0,0 +1,34 @@ +# Fixture i131_condition_id_single_with_real_folder +# Expected: E_SINGLE_MISUSE +# Source: Owner decision SQ6 (7 Nov 2026; matrix audit MS-1) +# The condition ID `single` names the folder `CTRL`: `single` means no condition folder level and is the ID of `source_folder = "single"` only (experiment-v1 3.2). +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.single] +source_folder = "CTRL" + +[channels.ACTIN] +header_names = ["Actin"] + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[repeats.single] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.single.members.single] +source_repeat = "single" +image_count = 4 + +[repeats.single.members.single.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[used_ids] +conditions = ["single"] +channels = ["ACTIN", "DNA"] +repeats = ["single"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i132_condition_single_folder_other_id.toml b/tests/spec_fixtures/fixtures/invalid/i132_condition_single_folder_other_id.toml new file mode 100644 index 00000000..dd8e6cf0 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i132_condition_single_folder_other_id.toml @@ -0,0 +1,34 @@ +# Fixture i132_condition_single_folder_other_id +# Expected: E_SINGLE_MISUSE +# Source: Owner decision SQ6 (7 Nov 2026; matrix audit MS-1) +# The condition `flat` names `source_folder = "single"`: a condition ID is `single` if and only if its folder is `single` (experiment-v1 3.2). +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.flat] +source_folder = "single" + +[channels.ACTIN] +header_names = ["Actin"] + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[repeats.single] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.single.members.flat] +source_repeat = "single" +image_count = 4 + +[repeats.single.members.flat.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[used_ids] +conditions = ["flat"] +channels = ["ACTIN", "DNA"] +repeats = ["single"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i133_condition_single_not_alone.toml b/tests/spec_fixtures/fixtures/invalid/i133_condition_single_not_alone.toml new file mode 100644 index 00000000..cae41be1 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i133_condition_single_not_alone.toml @@ -0,0 +1,45 @@ +# Fixture i133_condition_single_not_alone +# Expected: E_SINGLE_MISUSE +# Source: Owner decision SQ6 (7 Nov 2026; matrix audit MS-1) +# The condition `single` (no condition folder level) is declared next to the condition DrugA: a condition whose `source_folder` is `single` must be the only condition of the file (experiment-v1 3.2). +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.DrugA] +source_folder = "DrugA" + +[conditions.single] +source_folder = "single" + +[channels.ACTIN] +header_names = ["Actin"] + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[repeats.single] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.single.members.DrugA] +source_repeat = "single" +image_count = 4 + +[repeats.single.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.single.members.single] +source_repeat = "single" +image_count = 4 + +[repeats.single.members.single.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[used_ids] +conditions = ["DrugA", "single"] +channels = ["ACTIN", "DNA"] +repeats = ["single"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i134_duplicate_key_inline_table.toml b/tests/spec_fixtures/fixtures/invalid/i134_duplicate_key_inline_table.toml new file mode 100644 index 00000000..01fc0e63 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i134_duplicate_key_inline_table.toml @@ -0,0 +1,70 @@ +# Fixture i134_duplicate_key_inline_table +# Expected: E_DUPLICATE_KEY +# Source: Stage 1 design PR 0 (7 Nov 2026); experiment-v1 2.2 +# `display_name` written twice inside the inline table of the channel DNA: a key declared twice "in any spelling TOML treats as the same key (bare vs quoted, table vs dotted vs inline)" is `E_DUPLICATE_KEY` (experiment-v1 2.2), not `E_TOML_SYNTAX`. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" +display_name = "Control" + +[conditions.DrugA] +source_folder = "DrugA" +display_name = "Drug A" +reference = "CTRL" +target = "SRC" +treatment_key = "saracatinib" + +[channels] +DNA = { display_name = "DAPI", display_name = "DNA", header_names = ["DAPI"] } + +[channels.ACTIN] +display_name = "F-actin" +header_names = ["Phalloidin-488"] + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep1.members.CTRL] +source_repeat = "rep1" +image_count = 12 + +[repeats.rep1.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 11 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.rep2] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep2.members.CTRL] +source_repeat = "rep2" +image_count = 10 + +[repeats.rep2.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.rep2.members.DrugA] +source_repeat = "rep2" +image_count = 10 + +[repeats.rep2.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1", "rep2"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/valid/v35_flat_experiment.toml b/tests/spec_fixtures/fixtures/valid/v35_flat_experiment.toml index bcf74856..eb3ee8e5 100644 --- a/tests/spec_fixtures/fixtures/valid/v35_flat_experiment.toml +++ b/tests/spec_fixtures/fixtures/valid/v35_flat_experiment.toml @@ -1,7 +1,7 @@ # Fixture v35_flat_experiment # Expected: PASS # Source: Matrix audit 2026-11-07 (case F01) -# An experiment folder that is itself the leaf: no condition, biological-repeat or technical-repeat folder. Preprocessing labels all three levels `single` (contract 1). Valid as written under 3.2 and 4.6a, but 4.2 defines `source_folder` as the condition folder label read from disk, which does not exist here: whether `single` is the condition's `source_folder` (and its proposed ID) is an open spec question (matrix audit MS-1). +# An experiment folder that is itself the leaf: no condition, biological-repeat or technical-repeat folder. Preprocessing labels all three levels `single` (contract 1). The condition `single` has `source_folder = "single"` ("no condition folder level", 3.2, 4.2, 4.6a) and is the only condition; see i131-i133 for the refused misuses (owner decision SQ6, matrix audit MS-1). schema = "morphobricks-experiment" schema_version = 1 diff --git a/tests/spec_fixtures/fixtures/valid/v36_repeat_folder_forbidden_name.toml b/tests/spec_fixtures/fixtures/valid/v36_repeat_folder_forbidden_name.toml index 790873f3..6a5e9300 100644 --- a/tests/spec_fixtures/fixtures/valid/v36_repeat_folder_forbidden_name.toml +++ b/tests/spec_fixtures/fixtures/valid/v36_repeat_folder_forbidden_name.toml @@ -1,7 +1,7 @@ # Fixture v36_repeat_folder_forbidden_name # Expected: PASS # Source: Matrix audit 2026-11-07 (case F16) -# Biological-repeat folders named `z` (a forbidden ID key, 4.7) in both conditions are paired into the repeat `R1`; `source_repeat` is never restricted. The default repeat ID would be `z` (3.1), which is refused (i130): 4.6a's 'propose another ID' rule names conditions and technical repeats only, so the repeat case is an open spec question (matrix audit MS-2). The condition folder `control` is the condition `ctrl` (as v33). +# Biological-repeat folders named `z` (a forbidden ID key, 4.7) in both conditions are paired into the repeat `R1`; `source_repeat` is never restricted. The proposed repeat ID would be the folder label `z`, which is not allowed (a forbidden name, 4.7; i130), so preprocessing makes no proposal and the user names the repeat ID `R1` (4.6a; owner decision SQ6, matrix audit MS-2). The condition folder `control` is the condition `ctrl` (as v33). schema = "morphobricks-experiment" schema_version = 1 diff --git a/tests/spec_fixtures/tools/audit/refval.py b/tests/spec_fixtures/tools/audit/refval.py index bff88079..2ce3a512 100644 --- a/tests/spec_fixtures/tools/audit/refval.py +++ b/tests/spec_fixtures/tools/audit/refval.py @@ -110,7 +110,7 @@ def phase1(data: bytes, r: Report): return tomllib.loads(text) except tomllib.TOMLDecodeError as exc: msg = str(exc) - dup = ("Cannot declare" in msg) or ("Cannot overwrite" in msg) or ("twice" in msg) + dup = ("Cannot declare" in msg) or ("Cannot overwrite" in msg) or ("twice" in msg) or ("Duplicate" in msg) r.e("E_DUPLICATE_KEY" if dup else "E_TOML_SYNTAX", "", msg) return None @@ -341,6 +341,10 @@ def label_id(v, path, scope): p = kpath("conditions", cid) label_id(cid, p, "conditions") label_id(c["source_folder"], p + ".source_folder", "condition_folders") + if (cid == "single") != (c["source_folder"] == "single"): + r.e("E_SINGLE_MISUSE", p + ".source_folder", c["source_folder"]) + elif c["source_folder"] == "single" and len(doc["conditions"]) > 1: + r.e("E_SINGLE_MISUSE", p + ".source_folder", "single is not the only condition") if "reference" in c: _nfc(c["reference"], p + ".reference", r) # a reference is NFC-checked only for chid in doc["channels"]: diff --git a/tests/test_spec_reader.py b/tests/test_spec_reader.py index 552b8750..5d4f68d6 100644 --- a/tests/test_spec_reader.py +++ b/tests/test_spec_reader.py @@ -24,7 +24,7 @@ def _name(item): def test_the_fixture_counts_are_the_catalogue_s(): - assert (len(VALID), len(INVALID), len(TRANSITIONS)) == (36, 130, 12) + assert (len(VALID), len(INVALID), len(TRANSITIONS)) == (36, 134, 12) @pytest.mark.parametrize("item", VALID, ids=_name) diff --git a/tests/test_spec_reader_phases_1_3.py b/tests/test_spec_reader_phases_1_3.py index f19d2edd..f1e7de49 100644 --- a/tests/test_spec_reader_phases_1_3.py +++ b/tests/test_spec_reader_phases_1_3.py @@ -27,7 +27,7 @@ def _name(item): def test_the_first_three_phases_have_their_fixtures(): - assert len(EARLY) == 65 + assert len(EARLY) == 66 assert {item.phase for item in EARLY} == {1, 2, 3}