From f2eb21a575591c8ec35ba20e9206a9d89e5639e8 Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Tue, 25 Aug 2026 20:34:01 +0200 Subject: [PATCH 1/5] docs: name the axis rules the RFC-3 versions leave inert The page said the axis rules are inert for the versions that adopt the RFC-3 axis model. axis-names-unique is not among them: RFC-3 states it and no released schema carries it, so it applies at every version. Only the count, type and order rules stand down. --- docs/validation/api.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/validation/api.md b/docs/validation/api.md index 4733f151..dfe7e50a 100644 --- a/docs/validation/api.md +++ b/docs/validation/api.md @@ -44,9 +44,10 @@ from ngff_zarr import ( `validate_structural(metadata, options=None, version=None)` runs the image/multiscales rules. When `options` is `None` it uses `ValidateOptions()`, i.e. `ValidationLevel.STRICT`. `version` is the OME-Zarr version the metadata -declares; the axis rules are inert for the versions that adopt the RFC-3 axis -model (see [[parity]]), so omitting it holds every store to the v0.4 axis -caps. A `ValidationError` carries `.rule` (a `SpecRule`), +declares; the axis count, type and order rules are inert for the versions that +adopt the RFC-3 axis model (see [[parity]]), so omitting it holds every store to +the v0.4 axis caps. `axis-names-unique` is never inert: RFC-3 states it and no +released schema carries it. A `ValidationError` carries `.rule` (a `SpecRule`), `.message` (str), and `.location` (`str | None`); `str(exc)` is `Spec rule [] violated: `. From 7205c2e85e84f8bb1f07fa5a188b7440634e9846 Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Mon, 24 Aug 2026 19:15:48 +0200 Subject: [PATCH 2/5] fix(py,ts): gate the RFC-4 orientation rules on 0.9.dev1 RFC-4 anatomical orientation is normative from OME-Zarr 0.9.dev1 (ome/ngff-spec#190); the released 0.4, 0.5 and 0.6 specs give it no status. Decided in #667: the three axis-orientation rules now gate on the declared version, becoming a no-op below 0.9.dev1. An omitted version keeps the checks on, as a strictness choice, exactly like axis-names-unique below 0.9.dev1, so every existing no-version caller is unchanged. The gate mirrors the RFC-3 helper's form with the polarity inverted: RFC-3 lifts axis restrictions at 0.9.dev1 while RFC-4 adds requirements, so is_rfc4_orientation_enforced exits the rules early below 0.9.dev1 rather than at it. The Python v0.4 reader now passes the store's declared version to validate_structural, as the TypeScript reader already did. The readers' raw RFC-4 hooks are removed. They ran validate_rfc4 orientation on the raw axis dicts before parsing, which the structural pass repeats on the parsed axes with the same function and the same verdicts (the RFC-4 JSON Schema's root object declares no properties, so its final pass constrains nothing); they fired before the structural rules, out of the canonical evaluation order; and the v0.6 Python hook read a flat axes list the v0.6 layout does not have. The orientation rule is now the single enforcement point in each port. No rule is added, renamed or reordered. The parity suites pin the new version set as CANONICAL_RFC4_VERSIONS in both ports. Two tests that pinned the raw hooks' read-path rejection now pin the read-path acceptance below 0.9.dev1; their fixtures carried an (x, y, z) spatial order that the hooks' early raise had kept from the structural pass. Refs #667 --- py/ngff_zarr/structural_validation.py | 44 +++++-- py/ngff_zarr/v04/zarr_metadata.py | 34 ++--- py/ngff_zarr/v06/zarr_metadata.py | 18 +-- py/test/test_rfc4_validation.py | 64 ++++++---- .../test_structural_validation_orientation.py | 27 ++++ py/test/test_structural_validation_parity.py | 106 ++++++++++++++++ py/test/test_structural_validation_reader.py | 37 ++++++ ts/src/types/supported_versions.ts | 19 +++ ts/src/utils/from_zarr_attrs.ts | 53 ++------ ts/src/utils/structural_validation.ts | 24 +++- ts/test/rfc4_validation_test.ts | 71 ++++++----- .../structural_validation_orientation_test.ts | 23 ++++ ts/test/structural_validation_parity_test.ts | 118 ++++++++++++++++++ ts/test/structural_validation_reader_test.ts | 69 +++++++++- 14 files changed, 555 insertions(+), 152 deletions(-) diff --git a/py/ngff_zarr/structural_validation.py b/py/ngff_zarr/structural_validation.py index a15d1b3b..367e2ca0 100644 --- a/py/ngff_zarr/structural_validation.py +++ b/py/ngff_zarr/structural_validation.py @@ -61,13 +61,13 @@ # each OMERO channel color is exactly 6 hex digits # e.g. multiscales[0].omero.channels[0].color # axis-orientation-anatomical-type -# each RFC 4 orientation type is "anatomical" (the only value defined) +# each RFC 4 orientation type is "anatomical"; normative from 0.9.dev1 # e.g. multiscales[0].axes # axis-orientation-on-non-space -# orientation is declared only on spatial axes (never time/channel) +# orientation only on spatial axes (never time/channel); from 0.9.dev1 # e.g. multiscales[0].axes # axis-orientation-unique-axis -# at most one direction per anatomical axis (antonyms are exclusive) +# one direction per anatomical axis (antonyms exclusive); from 0.9.dev1 # e.g. multiscales[0].axes # zarr-format # a v0.5 entry implies a Zarr v3 store (a leaked zarr_format must be 3) @@ -367,6 +367,24 @@ def is_rfc3_axis_model_allowed(version: object | None = None) -> bool: return version is not None and version == NgffVersion.V09dev1 +def is_rfc4_orientation_enforced(version: object | None = None) -> bool: + """Whether ``version`` makes the RFC-4 orientation rules normative. + + Only OME-Zarr ``0.9.dev1`` does (``ome/ngff-spec#190`` folds RFC-4 into + it): the released 0.4, 0.5 and 0.6 specs give ``orientation`` no normative + status, so when the caller declares one of those versions the orientation + rules are inert. ``None`` keeps them on: with no declared version, + enforcement is a strictness choice, exactly like ``axis-names-unique`` + below 0.9.dev1 (see docs/validation/rule-reference.md). + + The gate points the opposite way from :func:`is_rfc3_axis_model_allowed`: + at 0.9.dev1 RFC-3 *lifts* the axis restrictions while RFC-4 *adds* the + orientation requirements, so the rules exit early below 0.9.dev1 rather + than at it. + """ + return version is None or version == NgffVersion.V09dev1 + + def validate_axis_count(metadata: Metadata, version: object | None = None) -> None: """Validate that the axis count is within the v0.4-permitted range. @@ -752,7 +770,9 @@ def _axis_to_validation_dict(axis: Axis) -> dict[str, Any]: return axis_dict -def validate_axis_orientation(metadata: Metadata) -> None: +def validate_axis_orientation( + metadata: Metadata, version: object | None = None +) -> None: """Validate RFC 4 anatomical-orientation metadata on the spatial axes. This rule does not reimplement RFC 4; it wraps the package's existing logic @@ -766,6 +786,10 @@ def validate_axis_orientation(metadata: Metadata) -> None: no-op and the comparatively heavy ``jsonschema`` import inside :func:`validate_rfc4_orientation` is never triggered. + Inert when ``version`` declares a release below 0.9.dev1, where RFC-4 has + no normative status; ``None`` keeps the checks on (see + :func:`is_rfc4_orientation_enforced`). + Raises ------ ValidationError @@ -779,6 +803,8 @@ def validate_axis_orientation(metadata: Metadata) -> None: Propagated unchanged when an orientation value is outside the RFC 4 vocabulary -- a schema-level concern with no dedicated structural rule. """ + if not is_rfc4_orientation_enforced(version): + return from .rfc4_validation import ( has_any_rfc4_orientation, validate_rfc4_orientation, @@ -1050,7 +1076,10 @@ def validate_structural( version: The OME-Zarr version the metadata declares. Rules 1-3 are inert for the versions that adopt the RFC-3 axis model, so omitting it holds every - store to the v0.4 axis caps. + store to the v0.4 axis caps. The RFC 4 orientation checks + (:func:`validate_axis_orientation`) gate the opposite way: they are + normative from 0.9.dev1, inert when an earlier version is declared, + and kept on when the version is omitted. Raises ------ @@ -1063,7 +1092,8 @@ def validate_structural( ----- This orchestrator covers the image/multiscales rules, including the RFC 4 anatomical-orientation checks (:func:`validate_axis_orientation`, a no-op - when no axis declares orientation). The HCS plate/well structural rules + when no axis declares orientation and inert when ``version`` declares a + release below 0.9.dev1). The HCS plate/well structural rules operate on separate metadata objects and are dispatched by the companion :func:`validate_plate` and :func:`validate_well` entry points. """ @@ -1082,7 +1112,7 @@ def validate_structural( validate_transform_order(metadata) validate_dataset_order(metadata) validate_omero_color_hex(metadata) - validate_axis_orientation(metadata) + validate_axis_orientation(metadata, version) validate_zarr_format_for_version(metadata) validate_ome_namespace(metadata) diff --git a/py/ngff_zarr/v04/zarr_metadata.py b/py/ngff_zarr/v04/zarr_metadata.py index 22393d38..02dc5f4d 100644 --- a/py/ngff_zarr/v04/zarr_metadata.py +++ b/py/ngff_zarr/v04/zarr_metadata.py @@ -441,11 +441,7 @@ def _from_zarr_attrs( from .._zarrista_utils import open_lazy_array from ..ngff_image import NgffImage - from ..parse_metadata import _parse_omero, _raw_axes - from ..rfc4_validation import ( - has_any_rfc4_orientation, - validate_rfc4_orientation, - ) + from ..parse_metadata import _parse_omero from ..validate import validate as validate_ngff # Validate structure before any processing to avoid cryptic KeyError @@ -478,17 +474,6 @@ def _from_zarr_attrs( else: validate_ngff(root_attrs, version=schema_version) - # RFC 4 validation for anatomical orientation. The axes are read - # through the shared helper, which knows where each version keeps - # them, and a non-dict axis entry is left to the schema check. - axes_dicts = [ - axis - for axis in _raw_axes(root_attrs["multiscales"][0]) - if isinstance(axis, dict) - ] - if axes_dicts and has_any_rfc4_orientation(axes_dicts): - validate_rfc4_orientation(axes_dicts) - omero = _parse_omero(root_attrs.get("omero")) # OME-Zarr v0.5 hoists the spec ``version`` to the group-level ``ome`` # namespace; v0.4 carries it on each multiscale entry. Capture the @@ -619,11 +604,14 @@ def _from_zarr_attrs( if validate: # Strict structural validation of the parsed Metadata, layered after - # the schema pass and RFC 4 dict check above. The structural rules - # encode the v0.4+ metadata model (typed axes and per-dataset - # coordinate transformations); the legacy v0.1-0.3 layouts predate - # it, so the rules are scoped to v0.4 and newer. Imported lazily so - # the default validate=False read path incurs no extra import cost. + # the schema pass above. The structural rules encode the v0.4+ + # metadata model (typed axes and per-dataset coordinate + # transformations); the legacy v0.1-0.3 layouts predate it, so the + # rules are scoped to v0.4 and newer. Imported lazily so the + # default validate=False read path incurs no extra import cost. + # The declared version is passed through so the version-gated rules + # (the RFC-3 axis rules and the RFC 4 orientation checks) apply + # exactly the requirements of the store's own version. from ..structural_validation import ( ValidateOptions, ValidationLevel, @@ -633,7 +621,9 @@ def _from_zarr_attrs( spec_version = packaging.version.parse(str(metadata.version)) if spec_version >= packaging.version.parse("0.4"): validate_structural( - metadata, ValidateOptions(level=ValidationLevel.STRICT) + metadata, + ValidateOptions(level=ValidationLevel.STRICT), + version=metadata.version, ) return metadata, images diff --git a/py/ngff_zarr/v06/zarr_metadata.py b/py/ngff_zarr/v06/zarr_metadata.py index f62cf4b3..6d89600c 100644 --- a/py/ngff_zarr/v06/zarr_metadata.py +++ b/py/ngff_zarr/v06/zarr_metadata.py @@ -731,11 +731,7 @@ def _from_zarr_attrs( from .._zarrista_utils import open_lazy_array from ..ngff_image import NgffImage - from ..parse_metadata import _parse_omero, _raw_axes - from ..rfc4_validation import ( - has_any_rfc4_orientation, - validate_rfc4_orientation, - ) + from ..parse_metadata import _parse_omero from ..validate import validate as validate_ngff # make sure root_attrs['ome]['multiscales'] exists @@ -778,18 +774,6 @@ def _from_zarr_attrs( schema_version = V06_ONDISK_VERSION.value validate_ngff(schema_attrs, version=schema_version) - # RFC 4 validation for anatomical orientation. From v0.6 the axes - # live in the intrinsic coordinate system, so they are read through - # the shared helper rather than from a flat ``axes`` key, which a - # v0.6 entry does not carry. - axes_dicts = [ - axis - for axis in _raw_axes(root_attrs["ome"]["multiscales"][0]) - if isinstance(axis, dict) - ] - if axes_dicts and has_any_rfc4_orientation(axes_dicts): - validate_rfc4_orientation(axes_dicts) - omero = _parse_omero(root_attrs.get("ome", {}).get("omero")) root_attrs = root_attrs["ome"]["multiscales"][0] diff --git a/py/test/test_rfc4_validation.py b/py/test/test_rfc4_validation.py index 2862f515..87395ceb 100644 --- a/py/test/test_rfc4_validation.py +++ b/py/test/test_rfc4_validation.py @@ -461,8 +461,15 @@ def test_from_ngff_zarr_with_rfc4_validation(tmp_path): assert multiscales is not None -def test_from_ngff_zarr_with_rfc4_validation_invalid(tmp_path): - """Test from_ngff_zarr with RFC 4 validation fails on invalid orientation.""" +def test_from_ngff_zarr_invalid_orientation_reads_below_rfc4(tmp_path): + """An invalid orientation reads back at 0.4 under validate=True. + + RFC 4 orientation is normative from OME-Zarr 0.9.dev1 only + (fideus-labs/ngff-zarr#667); this store declares 0.4, so its + out-of-vocabulary orientation value is read back without complaint. The + version gate lives in the read path alone: the module-level check rejects + the value at every version. + """ pytest.importorskip("jsonschema", reason="jsonschema required for RFC 4 validation") # Create a store with invalid RFC 4 orientation metadata @@ -475,31 +482,32 @@ def test_from_ngff_zarr_with_rfc4_validation_invalid(tmp_path): else: root.create_dataset("0", shape=(10, 10, 10), dtype="uint8") - # Add OME-NGFF metadata with incomplete RFC 4 orientation (missing z orientation) + axes = [ + { + "name": "z", + "type": "space", + "unit": "micrometer", + # Out-of-vocabulary value: rejected by validate_rfc4_orientation, + # tolerated by every pre-RFC-4 read. + "orientation": {"type": "anatomical", "value": "not-a-direction"}, + }, + { + "name": "y", + "type": "space", + "unit": "micrometer", + "orientation": {"type": "anatomical", "value": "anterior-to-posterior"}, + }, + { + "name": "x", + "type": "space", + "unit": "micrometer", + "orientation": {"type": "anatomical", "value": "right-to-left"}, + }, + ] multiscales_metadata = { "version": "0.4", "name": "test", - "axes": [ - { - "name": "x", - "type": "space", - "unit": "micrometer", - "orientation": {"type": "anatomical", "value": "right-to-left"}, - }, - { - "name": "y", - "type": "space", - "unit": "micrometer", - "orientation": {"type": "anatomical", "value": "anterior-to-posterior"}, - }, - { - "name": "z", - "type": "space", - "unit": "micrometer", - # Out-of-vocabulary value - this should cause validation to fail - "orientation": {"type": "anatomical", "value": "not-a-direction"}, - }, - ], + "axes": axes, "datasets": [ { "path": "0", @@ -512,9 +520,13 @@ def test_from_ngff_zarr_with_rfc4_validation_invalid(tmp_path): root.attrs["multiscales"] = [multiscales_metadata] - # Should fail on the out-of-vocabulary orientation value + # The module-level check rejects the out-of-vocabulary value... with pytest.raises(ValidationError, match="Invalid orientation value"): - from_ngff_zarr(store, validate=True) + validate_rfc4_orientation(axes) + + # ...and the 0.4 read path does not apply it: RFC 4 gates on 0.9.dev1. + multiscales = from_ngff_zarr(store, validate=True) + assert multiscales is not None def test_from_ngff_zarr_without_rfc4_validation(tmp_path): diff --git a/py/test/test_structural_validation_orientation.py b/py/test/test_structural_validation_orientation.py index 2815b0ea..9e4319ab 100644 --- a/py/test/test_structural_validation_orientation.py +++ b/py/test/test_structural_validation_orientation.py @@ -193,6 +193,33 @@ def test_axis_orientation_duplicate_anatomical_axis(): assert exc_info.value.location == "multiscales[0].axes" +def test_axis_orientation_version_gate(): + """The rule is inert when the caller declares a pre-RFC-4 version. + + RFC 4 orientation is normative from OME-Zarr 0.9.dev1 + (``ome/ngff-spec#190``); when the caller declares 0.4, 0.5 or 0.6 the same + metadata passes. With no version the rule stays on as a strictness choice, + which every no-version test above relies on. + """ + metadata = _metadata_with_axes( + [ + Axis(name="z", type="space", orientation=_orientation_dict(_LPS_VALUES[0])), + Axis( + name="y", + type="space", + orientation={"type": "other", "value": _LPS_VALUES[1]}, + ), + Axis(name="x", type="space", orientation=_orientation_dict(_LPS_VALUES[2])), + ] + ) + for version in ("0.4", "0.5", "0.6", "0.6.dev4"): + validate_axis_orientation(metadata, version=version) + for version in ("0.9.dev1", None): + with pytest.raises(ValidationError) as exc_info: + validate_axis_orientation(metadata, version=version) + assert exc_info.value.rule == SpecRule.AXIS_ORIENTATION_ANATOMICAL_TYPE, version + + def test_rfc4_orientation_messages_carry_mapping_markers(): """Pin the substrings :func:`validate_axis_orientation` maps onto rules. diff --git a/py/test/test_structural_validation_parity.py b/py/test/test_structural_validation_parity.py index 6e6b1841..69df19f2 100644 --- a/py/test/test_structural_validation_parity.py +++ b/py/test/test_structural_validation_parity.py @@ -84,6 +84,26 @@ if version.value not in CANONICAL_RFC3_VERSIONS ] + [None] +# The locked RFC-4 orientation version manifest: the versions at which the +# three axis-orientation rules are normative (ome/ngff-spec#190 folds RFC-4 +# into 0.9.dev1). This identical literal list appears in the Deno mirror test. +# The gate points the opposite way from CANONICAL_RFC3_VERSIONS: RFC-3 *lifts* +# the axis restrictions at 0.9.dev1 while RFC-4 *adds* the orientation +# requirements, so the rules are inert below these versions. No version at all +# keeps them on, as a strictness choice (like axis-names-unique). +CANONICAL_RFC4_VERSIONS = [ + "0.9.dev1", +] + +# Every other supported version must leave the orientation rules inert. Read +# off SUPPORTED_VERSIONS so a newly supported version has to be classified +# here rather than silently defaulting to either side. +PRE_RFC4_VERSIONS = [ + version.value + for version in SUPPORTED_VERSIONS + if version.value not in CANONICAL_RFC4_VERSIONS +] + # The canonical fail-fast evaluation order of the image/multiscales # orchestrator (validate_structural). Each entry is the SpecRule the # orchestrator must raise when that rule -- and every rule after it -- is @@ -423,6 +443,92 @@ def test_axis_names_unique_is_never_inert(): assert exc_info.value.rule == SpecRule.AXIS_NAMES_UNIQUE, version +# --------------------------------------------------------------------------- +# Manifest: the locked RFC-4 orientation version set +# --------------------------------------------------------------------------- + + +def _orientation_metadata(axes: list[Axis]) -> Metadata: + """Wrap ``axes`` in metadata that satisfies every non-orientation rule. + + The axis lists below are legal under both the restricted axis model and + RFC-3, so at every version the orientation rules alone decide the verdict. + """ + return Metadata( + axes=axes, + datasets=[ + Dataset( + path="0", + coordinateTransformations=[ + Scale([1.0] * len(axes)), + Translation([0.0] * len(axes)), + ], + ) + ], + coordinateTransformations=None, + ) + + +def _orientation_on_non_space_axes() -> list[Axis]: + """Orientation on the non-spatial time axis, and nowhere else. + + Only a stray orientation violates RFC 4 here, and it sits on the one axis + that may not carry it -- exercising the non-space arm the orchestrator + reaches even when no spatial axis is oriented. + """ + return [ + Axis( + name="t", + type="time", + orientation=_orientation("anatomical", "inferior-to-superior"), + ), + Axis(name="y", type="space"), + Axis(name="x", type="space"), + ] + + +def _duplicate_anatomical_axis_axes() -> list[Axis]: + """Two spatial axes on the one left-right anatomical axis.""" + return [ + Axis( + name="y", + type="space", + orientation=_orientation("anatomical", "left-to-right"), + ), + Axis( + name="x", + type="space", + orientation=_orientation("anatomical", "right-to-left"), + ), + ] + + +# One violating document per orientation rule. Each satisfies every other rule +# at every version, so the orientation rule alone decides accept or reject. +_RFC4_ORIENTATION_CASES = [ + ( + _valid_axes_with_inconsistent_orientation, + SpecRule.AXIS_ORIENTATION_ANATOMICAL_TYPE, + ), + (_orientation_on_non_space_axes, SpecRule.AXIS_ORIENTATION_ON_NON_SPACE), + (_duplicate_anatomical_axis_axes, SpecRule.AXIS_ORIENTATION_UNIQUE_AXIS), +] + + +def test_rfc4_orientation_version_manifest_is_locked(): + for build_axes, rule in _RFC4_ORIENTATION_CASES: + # Enforced at the manifest versions, and when no version is given... + for version in [*CANONICAL_RFC4_VERSIONS, None]: + with pytest.raises(ValidationError) as exc_info: + validate_structural( + _orientation_metadata(build_axes()), version=version + ) + assert exc_info.value.rule == rule, (rule, version) + # ...and inert at every earlier supported version. + for version in PRE_RFC4_VERSIONS: + validate_structural(_orientation_metadata(build_axes()), version=version) + + def _commented_rule_ids() -> list[str]: """The rule ids listed in the canonical table at the top of the module. diff --git a/py/test/test_structural_validation_reader.py b/py/test/test_structural_validation_reader.py index b51139b2..e7e08fba 100644 --- a/py/test/test_structural_validation_reader.py +++ b/py/test/test_structural_validation_reader.py @@ -187,6 +187,43 @@ def test_v05_namespacing_validated_when_ome_version_missing(tmp_path): assert exc_info.value.rule == SpecRule.OME_NAMESPACE +def _add_out_of_vocabulary_orientation(entry: dict) -> None: + """Give the entry's first (spatial) axis an orientation RFC 4 does not define.""" + entry["axes"][0]["orientation"] = { + "type": "anatomical", + "value": "up-to-down", + } + + +def test_v04_pre_rfc4_orientation_reads_cleanly_when_validating(tmp_path): + # RFC 4 orientation is normative from OME-Zarr 0.9.dev1 only; a 0.4 store + # whose orientation value is outside the RFC 4 vocabulary must read cleanly + # under validate=True. + store = _write_valid_2d_store(tmp_path / "valid.zarr") + root = zarr.open_group(str(store), mode="r+") + attrs = root.attrs.asdict() + _add_out_of_vocabulary_orientation(attrs["multiscales"][0]) + root.attrs["multiscales"] = attrs["multiscales"] + + multiscales = from_ngff_zarr(store, validate=True) + assert multiscales is not None + + +@requires_zarr_v3 +def test_v05_pre_rfc4_orientation_reads_cleanly_when_validating(tmp_path): + # The v0.5 read path shares the v0.4 parser and its RFC 4 hook; the same + # out-of-vocabulary orientation must read cleanly at 0.5 too. + store = _write_valid_2d_store_v05(tmp_path / "valid_v05.zarr") + root = zarr.open_group(str(store), mode="r+") + attrs = root.attrs.asdict() + ome = attrs["ome"] + _add_out_of_vocabulary_orientation(ome["multiscales"][0]) + root.attrs["ome"] = ome + + multiscales = from_ngff_zarr(store, validate=True) + assert multiscales is not None + + def test_scale_length_violation_raises_only_when_validating(tmp_path): store = _write_valid_2d_store(tmp_path / "valid.zarr") # Corrupt one dataset's scale to length 3 against the 2-axis (y, x) image. diff --git a/ts/src/types/supported_versions.ts b/ts/src/types/supported_versions.ts index 56d6d067..cbd2cb27 100644 --- a/ts/src/types/supported_versions.ts +++ b/ts/src/types/supported_versions.ts @@ -79,3 +79,22 @@ export function isV06Version(version: string): boolean { export function isRfc3AxisModelAllowed(version?: string): boolean { return version !== undefined && version === NgffVersion.V09dev1; } + +/** + * Whether a version makes the RFC-4 orientation rules normative. + * + * Only `0.9.dev1` does (ome/ngff-spec#190 folds RFC-4 into it): the released + * 0.4, 0.5 and 0.6 specs give `orientation` no normative status, so when the + * caller declares one of those versions the orientation rules are inert. + * `undefined` keeps them on: with no declared version, enforcement is a + * strictness choice, exactly like `axis-names-unique` below 0.9.dev1 (see + * docs/validation/rule-reference.md). + * + * The gate points the opposite way from {@link isRfc3AxisModelAllowed}: at + * 0.9.dev1 RFC-3 *lifts* the axis restrictions while RFC-4 *adds* the + * orientation requirements, so the rules exit early below 0.9.dev1 rather + * than at it. + */ +export function isRfc4OrientationEnforced(version?: string): boolean { + return version === undefined || version === NgffVersion.V09dev1; +} diff --git a/ts/src/utils/from_zarr_attrs.ts b/ts/src/utils/from_zarr_attrs.ts index ed7f4574..ce1c1741 100644 --- a/ts/src/utils/from_zarr_attrs.ts +++ b/ts/src/utils/from_zarr_attrs.ts @@ -24,10 +24,6 @@ import { NgffImage } from "../types/ngff_image.ts"; import type { AxesType, AxisUnit, SupportedDims } from "../types/units.ts"; import { parseOmero } from "./parse_metadata.ts"; import type { MemoryStore } from "../io/from_ngff_zarr.ts"; -import { - hasRfc4OrientationMetadata, - validateRfc4Orientation, -} from "./rfc4_validation.ts"; import { validateStructural, ValidationLevel, @@ -165,6 +161,15 @@ export async function fromZarrAttrsV04( const multiscalesMetadata = multiscalesArray[0] as Record; + // OME-Zarr v0.5 hoists the spec `version` to the group-level `ome` + // namespace; v0.4 carries it on each multiscale entry. Prefer the + // group-level value (which fromZarrAttrsV05 forwards as a top-level + // `version`) so validation sees the true spec version -- which the v0.5 + // namespacing rules and the version-gated structural rules read. v0.4 has no + // top-level version, so this falls back to the entry's. + const declaredVersion = (rootAttrs.version as string | undefined) ?? + (multiscalesMetadata.version as string | undefined) ?? "0.4"; + // Validate the root attributes against the OME-Zarr v0.4 schema if (validate) { // Basic structural validation @@ -187,24 +192,6 @@ export async function fromZarrAttrsV04( "Invalid OME-Zarr metadata: 'datasets' must be a non-empty array", ); } - - // RFC 4 validation for anatomical orientation - if ( - "axes" in multiscalesMetadata && - Array.isArray(multiscalesMetadata.axes) - ) { - const axesData = multiscalesMetadata.axes as Array< - Record - >; - // Filter to only dict-style axes for RFC4 validation - const axesDicts = axesData.filter( - (axis): axis is Record => - typeof axis === "object" && axis !== null, - ); - if (axesDicts.length > 0 && hasRfc4OrientationMetadata(axesDicts)) { - validateRfc4Orientation(axesDicts); - } - } } // Parse OMERO metadata @@ -442,14 +429,9 @@ export async function fromZarrAttrsV04( axes, datasets, name: (multiscalesMetadata.name as string) ?? "image", - // OME-Zarr v0.5 hoists the spec `version` to the group-level `ome` - // namespace; v0.4 carries it on each multiscale entry. Prefer the - // group-level value (which fromZarrAttrsV05 forwards as a top-level - // `version`) so the structural pass sees the true spec version -- which the - // v0.5 namespacing rules gate on. v0.4 has no top-level version, so this - // falls back to the entry's. - version: (rootAttrs.version as string | undefined) ?? - (multiscalesMetadata.version as string) ?? "0.4", + // The declared spec version, resolved above so the schema pass and the + // structural pass read the same value. + version: declaredVersion, omero, extra, coordinateTransformations: @@ -648,17 +630,6 @@ export async function fromZarrAttrsV06( } const axesOrientations = extractOrientationsFromAxes(intrinsicRawAxes); - // RFC 4 anatomical-orientation validation, mirroring the v0.4 reader. - if (validate) { - const axesDicts = intrinsicRawAxes.filter( - (axis): axis is Record => - typeof axis === "object" && axis !== null, - ); - if (axesDicts.length > 0 && hasRfc4OrientationMetadata(axesDicts)) { - validateRfc4Orientation(axesDicts); - } - } - // Open root group for array access (reuse consolidated metadata if present). let optimizedStore: MemoryStore | zarr.FetchStore | zarr.Readable; try { diff --git a/ts/src/utils/structural_validation.ts b/ts/src/utils/structural_validation.ts index c605fd9b..426148eb 100644 --- a/ts/src/utils/structural_validation.ts +++ b/ts/src/utils/structural_validation.ts @@ -40,6 +40,7 @@ import { import { formatNameList, pyRepr } from "./py_format.ts"; import { isRfc3AxisModelAllowed, + isRfc4OrientationEnforced, isV06Version, } from "../types/supported_versions.ts"; @@ -704,7 +705,12 @@ function axisToValidationRecord(axis: Axis): Record { * Orientation is optional in RFC 4, so when no spatial axis carries it the rule * is a no-op. * + * Inert when `version` declares a release below 0.9.dev1, where RFC-4 has no + * normative status; `undefined` keeps the checks on (see + * {@link isRfc4OrientationEnforced}). + * * @param metadata - The parsed multiscales metadata to validate. + * @param version - The OME-Zarr version the metadata declares. * @throws {ValidationError} With {@link SpecRule.AxisOrientationAnatomicalType} * when an orientation `type` is not `"anatomical"`, * {@link SpecRule.AxisOrientationOnNonSpace} when @@ -716,7 +722,13 @@ function axisToValidationRecord(axis: Axis): Record { * RFC 4 vocabulary, a schema-level concern with no dedicated structural rule * -- propagates unchanged. */ -export function validateAxisOrientation(metadata: Metadata): void { +export function validateAxisOrientation( + metadata: Metadata, + version?: string, +): void { + if (!isRfc4OrientationEnforced(version)) { + return; + } const axesRecords = metadata.axes.map(axisToValidationRecord); // A stray orientation on a non-spatial axis carries no spatial-axis // orientation, so hasRfc4OrientationMetadata alone would skip it; check for any @@ -985,7 +997,8 @@ export function validateWellAcquisition( * * This orchestrator covers the image/multiscales rules, including the RFC 4 * anatomical-orientation checks ({@link validateAxisOrientation}, a no-op when - * no axis declares orientation). The HCS plate/well structural rules operate on + * no axis declares orientation and inert when `version` declares a release + * below 0.9.dev1). The HCS plate/well structural rules operate on * separate metadata objects and are dispatched by the companion * {@link validatePlate} and {@link validateWell} entry points. * @@ -994,7 +1007,10 @@ export function validateWellAcquisition( * `{ level: "strict", allowUnknownFields: true }`. * @param version - The OME-Zarr version the metadata declares. Rules 1-3 are * inert for the versions that adopt the RFC-3 axis model, so omitting it holds - * every store to the v0.4 axis caps. + * every store to the v0.4 axis caps. The RFC 4 orientation checks + * ({@link validateAxisOrientation}) gate the opposite way: they are normative + * from 0.9.dev1, inert when an earlier version is declared, and kept on when + * the version is omitted. * @throws {ValidationError} For the first structural rule violated, carrying * the offending {@link SpecRule} and `location`. Never thrown under * {@link ValidationLevel.SchemaOnly}, which runs no structural rule. @@ -1021,7 +1037,7 @@ export function validateStructural( validateTransformOrder(metadata); validateDatasetOrder(metadata); validateOmeroColorHex(metadata); - validateAxisOrientation(metadata); + validateAxisOrientation(metadata, version); validateZarrFormatForVersion(metadata); validateOmeNamespace(metadata); } diff --git a/ts/test/rfc4_validation_test.ts b/ts/test/rfc4_validation_test.ts index ccb955e2..34abde69 100644 --- a/ts/test/rfc4_validation_test.ts +++ b/ts/test/rfc4_validation_test.ts @@ -339,31 +339,39 @@ Deno.test("fromNgffZarr with valid RFC-4 orientation - validation passes", async assertEquals(multiscales !== undefined, true); }); -Deno.test("fromNgffZarr with invalid RFC-4 orientation - throws error", async () => { +Deno.test("fromNgffZarr - an invalid orientation reads below the RFC-4 versions", async () => { + // RFC 4 orientation is normative from OME-Zarr 0.9.dev1 only + // (fideus-labs/ngff-zarr#667); this store declares 0.4, so its + // out-of-vocabulary orientation value is read back without complaint. The + // version gate lives in the read path alone: the module-level check rejects + // the value at every version. Mirrors the Python + // test_from_ngff_zarr_invalid_orientation_reads_below_rfc4. + const axes = [ + { + name: "z", + type: "space", + unit: "micrometer", + // Out-of-vocabulary value: rejected by validateRfc4Orientation, + // tolerated by every pre-RFC-4 read. + orientation: { type: "anatomical", value: "not-a-direction" }, + }, + { + name: "y", + type: "space", + unit: "micrometer", + orientation: { type: "anatomical", value: "anterior-to-posterior" }, + }, + { + name: "x", + type: "space", + unit: "micrometer", + orientation: { type: "anatomical", value: "right-to-left" }, + }, + ]; const multiscalesMetadata = { version: "0.4", name: "test", - axes: [ - { - name: "x", - type: "space", - unit: "micrometer", - orientation: { type: "anatomical", value: "right-to-left" }, - }, - { - name: "y", - type: "space", - unit: "micrometer", - orientation: { type: "anatomical", value: "anterior-to-posterior" }, - }, - { - name: "z", - type: "space", - unit: "micrometer", - // Out-of-vocabulary value - this should cause validation to fail - orientation: { type: "anatomical", value: "not-a-direction" }, - }, - ], + axes, datasets: [ { path: "0", @@ -376,18 +384,13 @@ Deno.test("fromNgffZarr with invalid RFC-4 orientation - throws error", async () const store = await createTestStore(multiscalesMetadata); - // Should fail on the out-of-vocabulary orientation value - let errorThrown = false; - try { - await fromNgffZarr(store, { validate: true }); - } catch (error) { - errorThrown = true; - assertEquals( - (error as Error).message.includes("Invalid orientation value"), - true, - ); - } - assertEquals(errorThrown, true); + // The module-level check rejects the out-of-vocabulary value... + const error = assertThrows(() => validateRfc4Orientation(axes), Error); + assertEquals(error.message.includes("Invalid orientation value"), true); + + // ...and the 0.4 read path does not apply it: RFC 4 gates on 0.9.dev1. + const multiscales = await fromNgffZarr(store, { validate: true }); + assertEquals(multiscales !== undefined, true); }); Deno.test("fromNgffZarr without validation - loads invalid data", async () => { diff --git a/ts/test/structural_validation_orientation_test.ts b/ts/test/structural_validation_orientation_test.ts index e592c3f7..484d2823 100644 --- a/ts/test/structural_validation_orientation_test.ts +++ b/ts/test/structural_validation_orientation_test.ts @@ -168,6 +168,29 @@ Deno.test("validateAxisOrientation - accepts partial orientation", () => { validateAxisOrientation(metadata); }); +Deno.test("validateAxisOrientation - inert below the RFC-4 versions", () => { + // RFC 4 orientation is normative from OME-Zarr 0.9.dev1 (ome/ngff-spec#190); + // when the caller declares 0.4, 0.5 or 0.6 the same metadata passes. With no + // version the rule stays on as a strictness choice, which every no-version + // test above relies on. Mirrors the Python + // test_axis_orientation_version_gate. + const metadata = metadataWithOrientations([ + { type: "anatomical", value: LPS_VALUES[0] }, + { type: "other", value: LPS_VALUES[1] }, + { type: "anatomical", value: LPS_VALUES[2] }, + ]); + for (const version of ["0.4", "0.5", "0.6", "0.6.dev4"]) { + validateAxisOrientation(metadata, version); + } + for (const version of ["0.9.dev1", undefined]) { + assertRuleViolation( + () => validateAxisOrientation(metadata, version), + SpecRule.AxisOrientationAnatomicalType, + "multiscales[0].axes", + ); + } +}); + Deno.test( "validateRfc4Orientation - messages carry the SpecRule mapping markers", () => { diff --git a/ts/test/structural_validation_parity_test.ts b/ts/test/structural_validation_parity_test.ts index f7270b14..fa244502 100644 --- a/ts/test/structural_validation_parity_test.ts +++ b/ts/test/structural_validation_parity_test.ts @@ -93,6 +93,24 @@ const NON_RFC3_VERSIONS: (string | undefined)[] = [ undefined, ]; +// The locked RFC-4 orientation version manifest: the versions at which the +// three axis-orientation rules are normative (ome/ngff-spec#190 folds RFC-4 +// into 0.9.dev1). This identical literal list appears in the Python twin. +// The gate points the opposite way from CANONICAL_RFC3_VERSIONS: RFC-3 *lifts* +// the axis restrictions at 0.9.dev1 while RFC-4 *adds* the orientation +// requirements, so the rules are inert below these versions. No version at all +// keeps them on, as a strictness choice (like axis-names-unique). +const CANONICAL_RFC4_VERSIONS: string[] = [ + "0.9.dev1", +]; + +// Every other supported version must leave the orientation rules inert. Read +// off SUPPORTED_VERSIONS so a newly supported version has to be classified +// here rather than silently defaulting to either side. +const PRE_RFC4_VERSIONS: string[] = SUPPORTED_VERSIONS + .map((version) => version as string) + .filter((version) => !CANONICAL_RFC4_VERSIONS.includes(version)); + // The canonical fail-fast evaluation order of the image/multiscales // orchestrator (validateStructural). Each entry is the SpecRule the // orchestrator must raise when that rule -- and every rule after it -- is @@ -480,3 +498,103 @@ Deno.test("axis-names-unique is never inert", () => { assertEquals(error.rule, SpecRule.AxisNamesUnique, String(version)); } }); + +// --------------------------------------------------------------------------- +// Manifest: the locked RFC-4 orientation version set +// --------------------------------------------------------------------------- + +/** + * Wrap `axes` in metadata that satisfies every non-orientation rule. + * + * The axis lists below are legal under both the restricted axis model and + * RFC-3, so at every version the orientation rules alone decide the verdict. + */ +function orientationMetadata(axes: Axis[]): Metadata { + return { + axes, + datasets: [ + { + path: "0", + coordinateTransformations: [ + createScale(axes.map(() => 1.0)), + createTranslation(axes.map(() => 0.0)), + ], + }, + ], + coordinateTransformations: undefined, + omero: undefined, + name: "image", + version: "0.4", + }; +} + +/** + * Orientation on the non-spatial time axis, and nowhere else. + * + * Only a stray orientation violates RFC 4 here, and it sits on the one axis + * that may not carry it -- exercising the non-space arm the orchestrator + * reaches even when no spatial axis is oriented. + */ +function orientationOnNonSpaceAxes(): Axis[] { + return [ + { + name: "t", + type: "time", + unit: undefined, + orientation: orientation("anatomical", "inferior-to-superior"), + }, + { name: "y", type: "space", unit: undefined }, + { name: "x", type: "space", unit: undefined }, + ]; +} + +/** Two spatial axes on the one left-right anatomical axis. */ +function duplicateAnatomicalAxisAxes(): Axis[] { + return [ + { + name: "y", + type: "space", + unit: undefined, + orientation: orientation("anatomical", "left-to-right"), + }, + { + name: "x", + type: "space", + unit: undefined, + orientation: orientation("anatomical", "right-to-left"), + }, + ]; +} + +// One violating document per orientation rule. Each satisfies every other rule +// at every version, so the orientation rule alone decides accept or reject. +const RFC4_ORIENTATION_CASES: Array<[() => Axis[], SpecRule]> = [ + [ + validAxesWithInconsistentOrientation, + SpecRule.AxisOrientationAnatomicalType, + ], + [orientationOnNonSpaceAxes, SpecRule.AxisOrientationOnNonSpace], + [duplicateAnatomicalAxisAxes, SpecRule.AxisOrientationUniqueAxis], +]; + +Deno.test("RFC-4 orientation version manifest is locked", () => { + for (const [buildAxes, rule] of RFC4_ORIENTATION_CASES) { + // Enforced at the manifest versions, and when no version is given... + for (const version of [...CANONICAL_RFC4_VERSIONS, undefined]) { + const error = assertThrows( + () => + validateStructural( + orientationMetadata(buildAxes()), + undefined, + version, + ), + ValidationError, + ); + assertEquals(error.rule, rule, String(version)); + } + // ...and inert at every earlier supported version. + for (const version of PRE_RFC4_VERSIONS) { + validateStructural(orientationMetadata(buildAxes()), undefined, version); + } + } +}); diff --git a/ts/test/structural_validation_reader_test.ts b/ts/test/structural_validation_reader_test.ts index dc0c5dfd..af911dfa 100644 --- a/ts/test/structural_validation_reader_test.ts +++ b/ts/test/structural_validation_reader_test.ts @@ -341,7 +341,7 @@ async function createOmeNamespacedStore( /** A single-level `ome` block over `axes`, tagged with the given version. */ function omeBlock( version: string, - axes: Array<{ name: string; type: string }>, + axes: Array>, ): Record { const rank = axes.length; return { @@ -427,3 +427,70 @@ Deno.test( assertEquals(result.images[0].dims, ["i", "j"]); }, ); + +// --- The declared version drives the RFC 4 orientation checks on read --- + +/** `(t, y, x)` axes with an orientation on the non-spatial time axis. */ +const NON_SPACE_ORIENTED_AXES: Array> = [ + { + name: "t", + type: "time", + orientation: { type: "anatomical", value: "inferior-to-superior" }, + }, + { name: "y", type: "space" }, + { name: "x", type: "space" }, +]; + +Deno.test( + "reader - a 0.6 store with orientation on a non-space axis reads under validate", + async () => { + // RFC 4 orientation is normative from OME-Zarr 0.9.dev1 only; at 0.6 a + // declared orientation is read back but not validated. + const store = await createOmeNamespacedStore( + omeBlock("0.6.dev4", NON_SPACE_ORIENTED_AXES), + [2, 2, 2], + ); + + const result = await fromOmeZarr(store, { validate: true }); + assertEquals(result.images[0].dims, ["t", "y", "x"]); + }, +); + +Deno.test( + "reader - the same store tagged 0.9.dev1 is refused", + async () => { + // The negative control: at 0.9.dev1 the orientation rules are normative, + // so the same document is refused with the on-non-space rule. + const store = await createOmeNamespacedStore( + omeBlock("0.9.dev1", NON_SPACE_ORIENTED_AXES), + [2, 2, 2], + ); + + const error = await assertRejects( + () => fromOmeZarr(store, { validate: true }), + Error, + ); + assertStringIncludes(error.message, SpecRule.AxisOrientationOnNonSpace); + }, +); + +Deno.test( + "fromZarrAttrsV04 - an out-of-vocabulary orientation reads under validate", + async () => { + // The v0.4 reader hook takes the same gate: an orientation value outside + // the RFC 4 vocabulary reads cleanly at 0.4. + const metadata = buildValidImageMetadata(); + (metadata.axes as Array>)[0].orientation = { + type: "anatomical", + value: "up-to-down", + }; + const store = await createImageStore(metadata); + + const result = await fromZarrAttrsV04( + { multiscales: [metadata] }, + store, + true, + ); + assertExists(result.metadata); + }, +); From 63b6311f95c94ece8d05978bcca9ac9fb3fc334f Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Mon, 24 Aug 2026 19:15:49 +0200 Subject: [PATCH 3/5] docs: describe the RFC-4 orientation version gate The three axis-orientation rules are normative from OME-Zarr 0.9.dev1 and inert when the caller declares an earlier version; with no version they stay on as a strictness choice, like axis-names-unique. Said in the rule-reference table rows and intro, the overview's scope paragraph, and the parity contract, which grows a sixth dimension and the CANONICAL_RFC4_VERSIONS manifest. Refs #667 --- docs/validation/overview.md | 3 +++ docs/validation/parity.md | 19 ++++++++++++++++++- docs/validation/rule-reference.md | 30 ++++++++++++++++++------------ 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/docs/validation/overview.md b/docs/validation/overview.md index 83a9363e..83c67633 100644 --- a/docs/validation/overview.md +++ b/docs/validation/overview.md @@ -26,6 +26,9 @@ finest-to-coarsest dataset ordering, OMERO channel color format, RFC 4 anatomical orientation, and HCS plate/well consistency. Most rules enforce v0.4 MUSTs; two additional rules (`zarr-format`, `ome-namespace`) enforce the v0.5 `ome`-namespace and Zarr v3 store conventions and fire only for v0.5 metadata. +The three RFC 4 orientation rules are normative from OME-Zarr 0.9.dev1: they +are inert when the caller declares an earlier version and stay on, as a +strictness choice, when no version is declared (see [[rule-reference]]). The rules operate on the already-parsed metadata object (the `Metadata`, `Plate`, and `Well` dataclasses in Python; their equivalents in TypeScript), so diff --git a/docs/validation/parity.md b/docs/validation/parity.md index a7bb1f27..f53dda30 100644 --- a/docs/validation/parity.md +++ b/docs/validation/parity.md @@ -26,7 +26,7 @@ for usage, see [[api]]. ## The contract -Both ports must agree on five observable dimensions: +Both ports must agree on six observable dimensions: 1. **Rule identifiers** — the same `SpecRule` string values, in the same canonical declaration/iteration order. @@ -42,6 +42,10 @@ Both ports must agree on five observable dimensions: inert for the versions that adopt the RFC-3 free-form axis model. Both ports must treat exactly the same version strings as RFC-3, or the same metadata validates in one language and not the other. +6. **The RFC-4 orientation version set** — the three orientation rules gate the + opposite way: they are normative at exactly the versions that adopt RFC-4 + (and when no version is given), and inert below. Both ports must treat the + same version strings as RFC-4 for the same reason. Because both test suites assert these facts against the **same literal identifier list**, adding, removing, renaming, or reordering a rule — or @@ -85,6 +89,15 @@ at every other. Rule 4, `axis-names-unique`, is never inert: RFC-3 *adds* it, and ngff-zarr applies it at all versions as a strictness choice (see [[rule-reference]]). +The versions at which the RFC-4 orientation rules are normative are pinned the +same way, as a `CANONICAL_RFC4_VERSIONS` literal: + +1. `0.9.dev1` + +Rules 9–11 gate the opposite way from the RFC-3 set: they are enforced at +those versions and when no version is given, and inert at every earlier +version, where RFC-4 has no normative status (see [[rule-reference]]). + ## The parity tests | Language | Test file | @@ -116,6 +129,10 @@ Each suite independently locks: `CANONICAL_RFC3_VERSIONS` and enforced at every other supported version and when no version is given, asserted through the public orchestrator rather than through the internal predicate. +- **RFC-4 orientation version set** — the orientation rules are enforced at + exactly the versions in `CANONICAL_RFC4_VERSIONS` (and when no version is + given) and inert at every other supported version, asserted the same way, + once per orientation rule. Four of the fifteen rules never appear in `EXPECTED_EVALUATION_ORDER`, each for its own reason. The two v0.5 namespacing rules (`zarr-format`, `ome-namespace`) diff --git a/docs/validation/rule-reference.md b/docs/validation/rule-reference.md index 2aea8ae1..41efc435 100644 --- a/docs/validation/rule-reference.md +++ b/docs/validation/rule-reference.md @@ -23,8 +23,12 @@ stable, lower-kebab-case identifier (the `SpecRule` value) that is identical across the Python and TypeScript ports — see [[parity]] for the guarantee. Most rules enforce OME-Zarr v0.4 MUSTs; the two v0.5 namespacing rules (`zarr-format`, `ome-namespace`) fire only for v0.5 metadata and are inert (a -no-op) for v0.4. For the conceptual background and the two validation levels, -see [[overview]]; for invocation, see [[api]]. +no-op) for v0.4. The three RFC 4 orientation rules (9–11) are normative from +OME-Zarr 0.9.dev1, the version `ome/ngff-spec#190` folds RFC-4 into: they are +inert when the caller declares 0.4, 0.5 or 0.6, where RFC 4 has no normative +status, and stay on when no version is declared — a strictness choice, like +`axis-names-unique` below 0.9.dev1. For the conceptual background and the two +validation levels, see [[overview]]; for invocation, see [[api]]. Location strings are dotted-segment, JSON-Pointer-style identifiers of the offending metadata node, emitted byte-for-byte identically in both languages. @@ -44,9 +48,9 @@ the `SpecRule` enum declares them and the orchestrators evaluate them. | 6 | `global-coord-transform-after-per-level` | images/multiscales | Exactly one `scale` per dataset, and a `translation` must follow — not precede — its `scale`. | v0.4: each dataset defines exactly one scale; a translation follows its scale. | `multiscales[0].datasets[1].coordinateTransformations` | | 7 | `dataset-order-highest-to-lowest` | images/multiscales | Datasets ordered finest → coarsest; the spatial scale must not decrease as the level index rises. | v0.4: multiscale datasets ordered from highest to lowest resolution. | `multiscales[0].datasets[2]` | | 8 | `omero-channel-color-format` | OMERO | Each OMERO channel `color` is exactly six hexadecimal digits (RGB). | v0.4: OMERO channel color is 6 hex digits. | `multiscales[0].omero.channels[0].color` | -| 9 | `axis-orientation-anatomical-type` | RFC 4 orientation | Every declared spatial-axis `orientation` has `type` `anatomical`. | RFC 4: an orientation's `type` is `anatomical`. | `multiscales[0].axes` | -| 10| `axis-orientation-on-non-space` | RFC 4 orientation | An `orientation` is declared only on `space` axes, never on a non-spatial axis. | RFC 4: orientation applies to spatial axes only. | `multiscales[0].axes[0]` | -| 11| `axis-orientation-unique-axis` | RFC 4 orientation | No two spatial axes declare orientations describing the same anatomical axis. | RFC 4: each spatial axis describes a distinct anatomical axis. | `multiscales[0].axes` | +| 9 | `axis-orientation-anatomical-type` | RFC 4 orientation | Every declared spatial-axis `orientation` has `type` `anatomical`. Inert below 0.9.dev1 when the caller declares a version; kept on when none is given. | RFC 4 (normative from 0.9.dev1): an orientation's `type` is `anatomical`. No released spec below 0.9.dev1 adopts RFC 4, so enforcement without a declared version is a strictness choice, not a spec MUST of those versions. | `multiscales[0].axes` | +| 10| `axis-orientation-on-non-space` | RFC 4 orientation | An `orientation` is declared only on `space` axes, never on a non-spatial axis. Inert below 0.9.dev1 when the caller declares a version; kept on when none is given. | RFC 4 (normative from 0.9.dev1): orientation applies to spatial axes only. Same below-0.9.dev1 status as rule 9. | `multiscales[0].axes[0]` | +| 11| `axis-orientation-unique-axis` | RFC 4 orientation | No two spatial axes declare orientations describing the same anatomical axis. Inert below 0.9.dev1 when the caller declares a version; kept on when none is given. | RFC 4 (normative from 0.9.dev1): each spatial axis describes a distinct anatomical axis. Same below-0.9.dev1 status as rule 9. | `multiscales[0].axes` | | 12| `zarr-format` | images/multiscales (v0.5) | A v0.5 entry implies a Zarr v3 store; a `zarr_format` value that leaked into the entry must be exactly `3`. Inert for v0.4. | v0.5: metadata is backed by a Zarr v3 store (`zarr_format == 3`). | `multiscales[0]` | | 13| `ome-namespace` | images/multiscales (v0.5) | A v0.5 entry must not retain a group-level `ome` or `multiscales` wrapper key — the `ome` namespace wraps the group attributes, not each entry. Inert for v0.4. | v0.5: multiscales live under the top-level `ome` namespace, with `version` hoisted to `ome.version`. | `multiscales[0]` | | 14| `plate-row-index-consistency` | HCS plate | Each well's `path` is `/`, naming declared row/column entries, with `rowIndex`/`columnIndex` equal to those entries' positions. | v0.4: well `rowIndex`/`columnIndex` match the named row/column positions in `plate.rows`/`plate.columns`. | `plate.wells[3]` | @@ -65,11 +69,12 @@ rules: class-ordering then spatial-name ordering for `axis-order`; per-dataset scale-count then transform-ordering for `global-coord-transform-after-per-level`. The v0.5 namespacing rules (12 and - 13) run last and are inert for v0.4 input. The orientation rules - `axis-orientation-on-non-space` (10) and `axis-orientation-unique-axis` (11) - fire only for specific axis shapes, so the linear fail-fast cascade for a v0.4 - metadata is an 11-step sequence ending at - `axis-orientation-anatomical-type`. + 13) run last and are inert for v0.4 input, and the orientation rules (9–11) + are inert when the caller declares a version below 0.9.dev1. The orientation + rules `axis-orientation-on-non-space` (10) and `axis-orientation-unique-axis` + (11) fire only for specific axis shapes, so the linear fail-fast cascade for + a v0.4-shaped metadata validated with no declared version is an 11-step + sequence ending at `axis-orientation-anatomical-type`. - **`validate_plate` / `validatePlate`** evaluates rule **14** (`plate-row-index-consistency`). - **`validate_well` / `validateWell`** evaluates rule **15** @@ -96,8 +101,9 @@ TypeScript by checking the target version in `axisViews`. `global-coord-transform-after-per-level`, `dataset-order-highest-to-lowest`. - **OMERO** — `omero-channel-color-format`. -- **RFC 4 orientation** — `axis-orientation-anatomical-type`, - `axis-orientation-on-non-space`, `axis-orientation-unique-axis`. +- **RFC 4 orientation** (normative from 0.9.dev1) — + `axis-orientation-anatomical-type`, `axis-orientation-on-non-space`, + `axis-orientation-unique-axis`. - **v0.5 namespacing** — `zarr-format`, `ome-namespace` (inert for v0.4). - **HCS plate / well** — `plate-row-index-consistency`, `well-acquisition-missing`. From 90edfcb1399a809a309d074f777a8ef841562905 Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Tue, 25 Aug 2026 00:15:00 +0200 Subject: [PATCH 4/5] test(py): read a 0.4 store with a non-space orientation under the version gate The reader test main added with #661 expected the non-space orientation rule to fire on a 0.4 store. RFC 4 gates on 0.9.dev1 here, so the 0.4 read path does not apply it and the document reads cleanly, as the neighbouring tests already state. The rule itself stays covered at the rule level. --- py/test/test_rfc4_validation.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/py/test/test_rfc4_validation.py b/py/test/test_rfc4_validation.py index 87395ceb..c6945fd1 100644 --- a/py/test/test_rfc4_validation.py +++ b/py/test/test_rfc4_validation.py @@ -643,11 +643,12 @@ def test_a_falsey_orientation_still_reaches_the_validator(orientation): validate_rfc4_orientation(axes) -def test_read_rejects_orientation_on_a_non_space_axis(tmp_path): - """The reader reaches the non-space rule with no spatial axis oriented. +def test_read_accepts_orientation_on_a_non_space_axis_below_0_9(tmp_path): + """A 0.4 store with an orientation on its time axis reads cleanly. - Gating on a spatial orientation left this document accepted: nothing in it - orients a space axis, which is exactly what makes it invalid. + The non-space rule is reachable with no spatial axis oriented (see + ``test_orientation_on_a_non_space_axis_is_reachable``), but RFC 4 gates on + 0.9.dev1, so the 0.4 read path does not apply it to this document. """ pytest.importorskip("jsonschema", reason="jsonschema required for RFC 4 validation") @@ -685,5 +686,5 @@ def test_read_rejects_orientation_on_a_non_space_axis(tmp_path): } ] - with pytest.raises(ValueError, match="non-space axes"): - from_ngff_zarr(store, validate=True) + multiscales = from_ngff_zarr(store, validate=True) + assert [axis.name for axis in multiscales.metadata.axes] == ["t", "y", "x"] From fe990eb2ed1acea4f84d19e7ae6e11d5f42537dd Mon Sep 17 00:00:00 2001 From: Valentin Boussot Date: Tue, 25 Aug 2026 20:34:22 +0200 Subject: [PATCH 5/5] docs: correct the sentence citing the RFC-4 reference "OME-Zarr 0.9.dev1, the version `ome/ngff-spec#190` folds RFC-4 into" reads the issue number as a version and leaves the verb without an object. The version is 0.9.dev1; ome/ngff-spec#190 is what incorporates RFC-4 into it. --- docs/validation/rule-reference.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/validation/rule-reference.md b/docs/validation/rule-reference.md index 41efc435..ad2f808b 100644 --- a/docs/validation/rule-reference.md +++ b/docs/validation/rule-reference.md @@ -24,7 +24,7 @@ across the Python and TypeScript ports — see [[parity]] for the guarantee. Mos rules enforce OME-Zarr v0.4 MUSTs; the two v0.5 namespacing rules (`zarr-format`, `ome-namespace`) fire only for v0.5 metadata and are inert (a no-op) for v0.4. The three RFC 4 orientation rules (9–11) are normative from -OME-Zarr 0.9.dev1, the version `ome/ngff-spec#190` folds RFC-4 into: they are +OME-Zarr 0.9.dev1, which incorporates RFC-4 through `ome/ngff-spec#190`: they are inert when the caller declares 0.4, 0.5 or 0.6, where RFC 4 has no normative status, and stay on when no version is declared — a strictness choice, like `axis-names-unique` below 0.9.dev1. For the conceptual background and the two