From 1c5c213086979ce1d34310e9b99c533cf61a1f23 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 08:43:13 +0000 Subject: [PATCH 1/3] Vendor the Spec conformance fixtures at a pinned commit (Stage 1, PR 1) Adds tests/spec_fixtures/: the MorphoBricks-Spec fixtures/ folder, docs/experiment-v1.md and the reference validator (a test oracle only) at Spec commit 8922a359ec26, with PIN.toml. - tests/test_spec_pin.py: the vendored fixtures/ folder must hash (git tree algorithm) to `git rev-parse :fixtures`; the two single files to their blob IDs. One changed, added, removed, renamed or CRLF-converted file fails. - tests/spec_gate.py: a skipped or xfailed test in any tests/test_spec_*.py is a failure. - tests/test_spec_no_fallback.py: no second table of codes or names, no lenient, legacy or default-version path in the reader modules (description*.py); the scan is itself tested. - tests/test_spec_vendored_missing.py: with the vendored tree removed the suite fails. - tests/test_spec_catalogue.py: every fixture has its header lines and a catalogue row. Tests only: no change to the package, the CLI, the notebook or any output. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Scma6W3LC11s3qjXHZuGDm --- CHANGELOG.md | 5 + docs/development.md | 17 + pyproject.toml | 3 +- tests/conftest.py | 7 + tests/spec_fixtures/.gitattributes | 3 + tests/spec_fixtures/PIN.toml | 13 + tests/spec_fixtures/README.md | 35 + tests/spec_fixtures/docs/experiment-v1.md | 780 ++++++++++++++++ tests/spec_fixtures/fixtures/README.md | 409 ++++++++ .../fixtures/invalid/i01_bom.toml | 43 + .../fixtures/invalid/i02_invalid_utf8.toml | 43 + .../i03_duplicate_condition_table.toml | 46 + .../i04_duplicate_key_quoted_vs_bare.toml | 46 + .../fixtures/invalid/i05_duplicate_field.toml | 44 + .../i06_duplicate_member_dotted_vs_table.toml | 44 + .../invalid/i07_unquoted_bareword.toml | 43 + .../invalid/i08_leading_zero_integer.toml | 43 + .../invalid/i09_schema_version_2.toml | 53 ++ .../i100_image_type_unknown_value.toml | 54 ++ .../invalid/i101_image_type_not_text.toml | 54 ++ .../invalid/i102_rgb_unit_wrong_order.toml | 78 ++ .../invalid/i103_rgb_unit_other_channels.toml | 54 ++ .../invalid/i104_rgb_token_in_plain_unit.toml | 74 ++ ...105_rgb_token_lowercase_in_plain_unit.toml | 53 ++ .../i106_rgb_channel_header_alias.toml | 79 ++ .../i107_channel_id_reserved_column.toml | 53 ++ .../i108_channel_id_reserved_casefold.toml | 53 ++ .../invalid/i109_mask_id_reserved_column.toml | 53 ++ .../invalid/i10_schema_version_0.toml | 53 ++ ...i110_header_names_duplicate_after_nfc.toml | 53 ++ ...111_mask_representation_parent_labels.toml | 53 ++ .../i112_mask_conversion_field_forbidden.toml | 54 ++ .../i113_forbidden_name_condition_key.toml | 53 ++ .../i114_forbidden_name_channel_key.toml | 53 ++ ...5_forbidden_name_technical_repeat_key.toml | 53 ++ .../invalid/i116_non_nfc_unit_channel.toml | 53 ++ .../invalid/i117_non_nfc_source_channel.toml | 53 ++ ...i118_channel_id_whitespace_and_syntax.toml | 53 ++ .../invalid/i119_rgb_unit_two_channels.toml | 78 ++ .../invalid/i11_schema_version_missing.toml | 52 ++ .../i120_condition_missing_source_folder.toml | 70 ++ ...echnical_repeat_missing_source_folder.toml | 70 ++ .../i122_two_condition_ids_one_folder.toml | 71 ++ ...3_two_technical_repeat_ids_one_folder.toml | 75 ++ ...4_one_technical_repeat_id_two_folders.toml | 71 ++ .../i125_single_id_with_real_folder.toml | 71 ++ ...26_used_ids_technical_repeats_missing.toml | 70 ++ ...7_technical_repeat_id_not_in_used_ids.toml | 71 ++ ...28_condition_folder_single_wrong_case.toml | 46 + .../i129_source_repeat_single_not_alone.toml | 58 ++ .../invalid/i12_schema_version_string.toml | 53 ++ .../i130_repeat_id_from_forbidden_folder.toml | 46 + .../invalid/i13_schema_version_float.toml | 53 ++ .../invalid/i14_schema_version_bool.toml | 53 ++ .../fixtures/invalid/i15_schema_id_wrong.toml | 53 ++ .../invalid/i16_unknown_top_level_field.toml | 54 ++ .../invalid/i17_unknown_condition_field.toml | 54 ++ .../invalid/i18_misspelled_field.toml | 53 ++ .../invalid/i19_dotted_float_key.toml | 59 ++ .../invalid/i20_per_fov_override.toml | 56 ++ .../invalid/i21_mask_axes_shared.toml | 55 ++ .../fixtures/invalid/i22_brick_routing.toml | 54 ++ .../invalid/i23_calibration_in_file.toml | 54 ++ .../fixtures/invalid/i24_control_boolean.toml | 54 ++ .../invalid/i25_experiment_field.toml | 54 ++ .../invalid/i26_source_index_field.toml | 54 ++ .../invalid/i27_toml_date_source_repeat.toml | 53 ++ .../invalid/i28_integer_source_repeat.toml | 53 ++ .../fixtures/invalid/i29_bool_label.toml | 53 ++ .../invalid/i30_confirmed_on_toml_date.toml | 53 ++ .../invalid/i31_confirmed_on_bad_format.toml | 53 ++ .../i32_confirmed_on_impossible_date.toml | 53 ++ .../invalid/i33_image_count_string.toml | 53 ++ .../invalid/i34_image_count_float.toml | 53 ++ .../invalid/i35_image_count_zero.toml | 53 ++ .../invalid/i36_confirmed_string.toml | 53 ++ .../invalid/i37_repeat_unconfirmed.toml | 53 ++ .../invalid/i38_mixed_type_channel_list.toml | 53 ++ .../invalid/i39_channels_as_string.toml | 53 ++ .../invalid/i40_empty_channel_list.toml | 53 ++ .../fixtures/invalid/i41_reference_list.toml | 53 ++ .../invalid/i42_representation_invalid.toml | 53 ++ .../invalid/i43_representation_missing.toml | 52 ++ .../invalid/i44_missing_channels_table.toml | 46 + .../i45_member_without_technical_repeats.toml | 50 + .../invalid/i46_header_name_empty.toml | 53 ++ .../invalid/i47_label_whitespace_only.toml | 53 ++ .../i48_case_collision_conditions.toml | 64 ++ .../invalid/i49_case_collision_channels.toml | 55 ++ .../i50_case_collision_source_repeats.toml | 53 ++ .../i51_case_collision_repeat_ids.toml | 65 ++ .../i52_case_collision_technical_repeats.toml | 53 ++ ...i53_display_name_collision_conditions.toml | 71 ++ .../invalid/i54_channel_id_missing_text.toml | 53 ++ .../i55_condition_outer_whitespace.toml | 53 ++ .../invalid/i56_source_repeat_null.toml | 53 ++ .../invalid/i57_channel_id_hyphen.toml | 53 ++ .../i58_channel_id_double_underscore.toml | 53 ++ .../invalid/i59_mask_id_reserved.toml | 53 ++ .../invalid/i60_mask_id_reserved_in.toml | 53 ++ .../invalid/i61_channel_id_reserved.toml | 53 ++ .../invalid/i62_single_wrong_case.toml | 53 ++ .../invalid/i63_single_not_alone.toml | 57 ++ .../fixtures/invalid/i64_reference_cycle.toml | 54 ++ .../invalid/i65_reference_cycle_long.toml | 66 ++ .../fixtures/invalid/i66_reference_self.toml | 53 ++ .../invalid/i67_reference_unknown.toml | 53 ++ .../invalid/i68_reference_case_mismatch.toml | 53 ++ .../invalid/i69_unknown_channel_in_unit.toml | 53 ++ .../i70_duplicate_channel_in_unit.toml | 53 ++ .../fixtures/invalid/i71_target_not_text.toml | 71 ++ .../invalid/i72_member_unknown_condition.toml | 61 ++ .../i73_condition_without_members.toml | 57 ++ .../i74_source_folder_in_two_repeats.toml | 77 ++ .../fixtures/invalid/i75_repeat_id_swap.toml | 65 ++ .../i76_display_name_collision_repeats.toml | 73 ++ .../i77_display_name_collision_channels.toml | 71 ++ .../invalid/i78_non_nfc_condition_key.toml | 71 ++ .../i79_mask_source_channel_unknown.toml | 53 ++ .../invalid/i80_mask_source_type_reused.toml | 57 ++ .../invalid/i81_header_names_duplicate.toml | 53 ++ .../i82_retired_repeats_forbidden.toml | 75 ++ .../invalid/i83_non_nfc_reference.toml | 71 ++ .../invalid/i84_non_nfc_source_repeat.toml | 71 ++ .../fixtures/invalid/i85_non_nfc_used_id.toml | 71 ++ .../i86_display_name_collision_case.toml | 71 ++ .../i87_display_name_collision_casefold.toml | 71 ++ ...8_display_name_collision_nfc_and_case.toml | 73 ++ .../i89_reference_names_display_name.toml | 71 ++ .../i90_member_keyed_by_display_name.toml | 71 ++ .../i91_unit_lists_channel_display_name.toml | 71 ++ .../invalid/i92_used_ids_missing.toml | 65 ++ .../invalid/i93_used_id_not_listed.toml | 71 ++ .../invalid/i94_used_ids_duplicate.toml | 71 ++ .../invalid/i95_used_id_case_collision.toml | 71 ++ .../invalid/i96_used_ids_mapping.toml | 74 ++ .../invalid/i97_used_ids_unknown_kind.toml | 72 ++ .../invalid/i98_confirmed_on_missing.toml | 70 ++ .../invalid/i99_labchronicle_provenance.toml | 75 ++ .../transitions/t01_repeat_id_changed.toml | 71 ++ .../transitions/t02_channel_id_changed.toml | 71 ++ .../transitions/t03_used_id_reused.toml | 61 ++ .../transitions/t04_used_id_dropped.toml | 48 + .../transitions/t05_confirmed_on_changed.toml | 71 ++ .../transitions/t06_member_added.toml | 104 +++ .../transitions/t07_member_removed.toml | 63 ++ .../transitions/t08_member_reassigned.toml | 69 ++ .../t09_case_correction_new_id.toml | 71 ++ .../t10_condition_id_changed_same_folder.toml | 71 ++ ...chnical_repeat_id_changed_same_folder.toml | 81 ++ .../t12_condition_folder_reassigned.toml | 71 ++ .../fixtures/valid/v01_single_control.toml | 71 ++ .../fixtures/valid/v02_multiple_controls.toml | 92 ++ .../v03_paired_different_folder_names.toml | 65 ++ .../valid/v04_swapped_channel_order.toml | 45 + .../valid/v05_variable_channel_counts.toml | 48 + ...6_technical_repeats_different_layouts.toml | 75 ++ .../fixtures/valid/v07_instance_masks.toml | 44 + .../fixtures/valid/v08_binary_masks.toml | 42 + .../fixtures/valid/v09_partial_masks.toml | 50 + .../fixtures/valid/v10_reference_chain.toml | 96 ++ .../fixtures/valid/v11_header_aliases.toml | 38 + .../fixtures/valid/v12_unnamed_headers.toml | 32 + .../valid/v13_homogeneous_legacy.toml | 113 +++ .../fixtures/valid/v14_no_repeat_folders.toml | 45 + .../valid/v15_toml_keys_are_text.toml | 45 + .../valid/v16_display_name_change.toml | 73 ++ .../valid/v17_partial_channel_presence.toml | 51 + .../valid/v18_inline_and_dotted_forms.toml | 37 + .../fixtures/valid/v19_warnings_unpaired.toml | 49 + .../valid/v20_unicode_and_spaces.toml | 46 + .../valid/v21_metadata_only_edit.toml | 70 ++ .../valid/v22_unused_channel_warning.toml | 55 ++ .../fixtures/valid/v23_nfc_identity.toml | 71 ++ .../valid/v24_display_name_as_typed.toml | 73 ++ .../v25_removed_condition_id_retained.toml | 48 + .../valid/v26_ids_kept_as_written.toml | 71 ++ .../valid/v27_descriptive_keys_collide.toml | 108 +++ .../fixtures/valid/v28_rgb_units.toml | 78 ++ .../valid/v29_rgb_and_channels_mixed.toml | 46 + ...0_same_source_label_different_repeats.toml | 65 ++ ...eader_alias_whitespace_and_decomposed.toml | 33 + .../valid/v32_reserved_name_near_misses.toml | 43 + ...33_condition_folder_on_forbidden_list.toml | 71 ++ ...nical_repeat_folder_on_forbidden_list.toml | 81 ++ .../fixtures/valid/v35_flat_experiment.toml | 34 + .../v36_repeat_folder_forbidden_name.toml | 67 ++ tests/spec_fixtures/tools/audit/refval.py | 875 ++++++++++++++++++ tests/spec_gate.py | 41 + tests/spec_gate_plugin.py | 9 + tests/spec_pin.py | 180 ++++ tests/spec_text.py | 89 ++ tests/test_spec_catalogue.py | 73 ++ tests/test_spec_gate.py | 62 ++ tests/test_spec_no_fallback.py | 202 ++++ tests/test_spec_pin.py | 148 +++ tests/test_spec_vendored_missing.py | 55 ++ tools/check.py | 8 +- 198 files changed, 13613 insertions(+), 3 deletions(-) create mode 100644 tests/spec_fixtures/.gitattributes create mode 100644 tests/spec_fixtures/PIN.toml create mode 100644 tests/spec_fixtures/README.md create mode 100644 tests/spec_fixtures/docs/experiment-v1.md create mode 100644 tests/spec_fixtures/fixtures/README.md create mode 100644 tests/spec_fixtures/fixtures/invalid/i01_bom.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i02_invalid_utf8.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i03_duplicate_condition_table.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i04_duplicate_key_quoted_vs_bare.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i05_duplicate_field.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i06_duplicate_member_dotted_vs_table.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i07_unquoted_bareword.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i08_leading_zero_integer.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i09_schema_version_2.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i100_image_type_unknown_value.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i101_image_type_not_text.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i102_rgb_unit_wrong_order.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i103_rgb_unit_other_channels.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i104_rgb_token_in_plain_unit.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i105_rgb_token_lowercase_in_plain_unit.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i106_rgb_channel_header_alias.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i107_channel_id_reserved_column.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i108_channel_id_reserved_casefold.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i109_mask_id_reserved_column.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i10_schema_version_0.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i110_header_names_duplicate_after_nfc.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i111_mask_representation_parent_labels.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i112_mask_conversion_field_forbidden.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i113_forbidden_name_condition_key.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i114_forbidden_name_channel_key.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i115_forbidden_name_technical_repeat_key.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i116_non_nfc_unit_channel.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i117_non_nfc_source_channel.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i118_channel_id_whitespace_and_syntax.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i119_rgb_unit_two_channels.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i11_schema_version_missing.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i120_condition_missing_source_folder.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i121_technical_repeat_missing_source_folder.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i122_two_condition_ids_one_folder.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i123_two_technical_repeat_ids_one_folder.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i124_one_technical_repeat_id_two_folders.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i125_single_id_with_real_folder.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i126_used_ids_technical_repeats_missing.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i127_technical_repeat_id_not_in_used_ids.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i128_condition_folder_single_wrong_case.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i129_source_repeat_single_not_alone.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i12_schema_version_string.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i130_repeat_id_from_forbidden_folder.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i13_schema_version_float.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i14_schema_version_bool.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i15_schema_id_wrong.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i16_unknown_top_level_field.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i17_unknown_condition_field.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i18_misspelled_field.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i19_dotted_float_key.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i20_per_fov_override.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i21_mask_axes_shared.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i22_brick_routing.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i23_calibration_in_file.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i24_control_boolean.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i25_experiment_field.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i26_source_index_field.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i27_toml_date_source_repeat.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i28_integer_source_repeat.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i29_bool_label.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i30_confirmed_on_toml_date.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i31_confirmed_on_bad_format.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i32_confirmed_on_impossible_date.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i33_image_count_string.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i34_image_count_float.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i35_image_count_zero.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i36_confirmed_string.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i37_repeat_unconfirmed.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i38_mixed_type_channel_list.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i39_channels_as_string.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i40_empty_channel_list.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i41_reference_list.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i42_representation_invalid.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i43_representation_missing.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i44_missing_channels_table.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i45_member_without_technical_repeats.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i46_header_name_empty.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i47_label_whitespace_only.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i48_case_collision_conditions.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i49_case_collision_channels.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i50_case_collision_source_repeats.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i51_case_collision_repeat_ids.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i52_case_collision_technical_repeats.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i53_display_name_collision_conditions.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i54_channel_id_missing_text.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i55_condition_outer_whitespace.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i56_source_repeat_null.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i57_channel_id_hyphen.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i58_channel_id_double_underscore.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i59_mask_id_reserved.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i60_mask_id_reserved_in.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i61_channel_id_reserved.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i62_single_wrong_case.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i63_single_not_alone.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i64_reference_cycle.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i65_reference_cycle_long.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i66_reference_self.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i67_reference_unknown.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i68_reference_case_mismatch.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i69_unknown_channel_in_unit.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i70_duplicate_channel_in_unit.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i71_target_not_text.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i72_member_unknown_condition.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i73_condition_without_members.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i74_source_folder_in_two_repeats.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i75_repeat_id_swap.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i76_display_name_collision_repeats.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i77_display_name_collision_channels.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i78_non_nfc_condition_key.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i79_mask_source_channel_unknown.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i80_mask_source_type_reused.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i81_header_names_duplicate.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i82_retired_repeats_forbidden.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i83_non_nfc_reference.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i84_non_nfc_source_repeat.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i85_non_nfc_used_id.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i86_display_name_collision_case.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i87_display_name_collision_casefold.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i88_display_name_collision_nfc_and_case.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i89_reference_names_display_name.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i90_member_keyed_by_display_name.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i91_unit_lists_channel_display_name.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i92_used_ids_missing.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i93_used_id_not_listed.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i94_used_ids_duplicate.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i95_used_id_case_collision.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i96_used_ids_mapping.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i97_used_ids_unknown_kind.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i98_confirmed_on_missing.toml create mode 100644 tests/spec_fixtures/fixtures/invalid/i99_labchronicle_provenance.toml create mode 100644 tests/spec_fixtures/fixtures/transitions/t01_repeat_id_changed.toml create mode 100644 tests/spec_fixtures/fixtures/transitions/t02_channel_id_changed.toml create mode 100644 tests/spec_fixtures/fixtures/transitions/t03_used_id_reused.toml create mode 100644 tests/spec_fixtures/fixtures/transitions/t04_used_id_dropped.toml create mode 100644 tests/spec_fixtures/fixtures/transitions/t05_confirmed_on_changed.toml create mode 100644 tests/spec_fixtures/fixtures/transitions/t06_member_added.toml create mode 100644 tests/spec_fixtures/fixtures/transitions/t07_member_removed.toml create mode 100644 tests/spec_fixtures/fixtures/transitions/t08_member_reassigned.toml create mode 100644 tests/spec_fixtures/fixtures/transitions/t09_case_correction_new_id.toml create mode 100644 tests/spec_fixtures/fixtures/transitions/t10_condition_id_changed_same_folder.toml create mode 100644 tests/spec_fixtures/fixtures/transitions/t11_technical_repeat_id_changed_same_folder.toml create mode 100644 tests/spec_fixtures/fixtures/transitions/t12_condition_folder_reassigned.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v01_single_control.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v02_multiple_controls.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v03_paired_different_folder_names.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v04_swapped_channel_order.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v05_variable_channel_counts.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v06_technical_repeats_different_layouts.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v07_instance_masks.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v08_binary_masks.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v09_partial_masks.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v10_reference_chain.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v11_header_aliases.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v12_unnamed_headers.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v13_homogeneous_legacy.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v14_no_repeat_folders.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v15_toml_keys_are_text.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v16_display_name_change.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v17_partial_channel_presence.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v18_inline_and_dotted_forms.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v19_warnings_unpaired.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v20_unicode_and_spaces.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v21_metadata_only_edit.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v22_unused_channel_warning.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v23_nfc_identity.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v24_display_name_as_typed.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v25_removed_condition_id_retained.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v26_ids_kept_as_written.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v27_descriptive_keys_collide.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v28_rgb_units.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v29_rgb_and_channels_mixed.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v30_same_source_label_different_repeats.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v31_header_alias_whitespace_and_decomposed.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v32_reserved_name_near_misses.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v33_condition_folder_on_forbidden_list.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v34_technical_repeat_folder_on_forbidden_list.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v35_flat_experiment.toml create mode 100644 tests/spec_fixtures/fixtures/valid/v36_repeat_folder_forbidden_name.toml create mode 100644 tests/spec_fixtures/tools/audit/refval.py create mode 100644 tests/spec_gate.py create mode 100644 tests/spec_gate_plugin.py create mode 100644 tests/spec_pin.py create mode 100644 tests/spec_text.py create mode 100644 tests/test_spec_catalogue.py create mode 100644 tests/test_spec_gate.py create mode 100644 tests/test_spec_no_fallback.py create mode 100644 tests/test_spec_pin.py create mode 100644 tests/test_spec_vendored_missing.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ff990ded..f6fc59fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ are in [docs/plan.md](docs/plan.md) §12. Upgrading from v0.3: see the ## Unreleased +- **Spec fixtures vendored (Stage 1, PR 1).** `tests/spec_fixtures/` holds the + MorphoBricks-Spec conformance fixtures at a pinned commit, with a git tree-hash pin test, + a gate that makes a skipped `tests/test_spec_*.py` test a failure, and the "no fallback + rules" scan (conformance plan sections 1 and 2). Tests only: no change to the package, + the CLI, the notebook or any output. - **Group statistics per channel (#104).** `group_summaries` and the notebook's step 4 no longer pool distinct channels together: each group has one summary per channel, and the intensity curves are drawn in one panel per channel. Single-channel tables give the same diff --git a/docs/development.md b/docs/development.md index bc419921..81c0ea12 100644 --- a/docs/development.md +++ b/docs/development.md @@ -101,6 +101,23 @@ is pinned to the release in `RELEASE_TAG` (plan §12 question 58); - Every acceptance check in the plan has a test. - A bug fix comes with a test that fails without it. +## Spec conformance tests + +The experiment-description reader is tested against the fixtures of +[MorphoBricks-Spec](https://github.com/CellMigrationLab/MorphoBricks-Spec) +(conformance plan, sections 1 and 2). They are vendored in `tests/spec_fixtures/` at the full +commit SHA in its `PIN.toml`, never a branch. + +- **Pin integrity:** `tests/test_spec_pin.py` fails when one byte of the vendored `fixtures/` + differs, or a file is added or missing (git tree hash against the pin), so a local edit + of a fixture cannot make a reader pass. Never edit the vendored files; updating the pin + is a reviewed change (`tests/spec_fixtures/README.md`). +- **Skip is failure:** every test module named `tests/test_spec_*.py` is held by + `tests/spec_gate.py`: a skipped or xfailed test there fails the run. +- **No second copy of the rules:** `tests/test_spec_no_fallback.py` scans the reader's + modules (`description*.py`) for a second table of codes or names and for lenient, older or + default-version paths. + ## Versioning - Semantic versioning for the package. diff --git a/pyproject.toml b/pyproject.toml index 068e1c53..54c0fb46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,8 @@ addopts = "-ra" line-length = 100 target-version = "py311" src = ["src", "tests", "tools"] -extend-exclude = ["tests/guard_fixtures"] +# The vendored Spec files are byte-exact copies (tests/spec_fixtures/README.md). +extend-exclude = ["tests/guard_fixtures", "tests/spec_fixtures"] [tool.ruff.lint] select = [ diff --git a/tests/conftest.py b/tests/conftest.py index 15d7e0d8..82b7800a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,3 +3,10 @@ # Lets tests import the shared simulation helpers. sys.path.insert(0, str(Path(__file__).parent)) + +from spec_gate import SkipIsFailure # noqa: E402 + + +def pytest_configure(config): + # A skipped Spec conformance test is a failure (conformance plan section 1). + config.pluginmanager.register(SkipIsFailure(), "spec-skip-is-failure") diff --git a/tests/spec_fixtures/.gitattributes b/tests/spec_fixtures/.gitattributes new file mode 100644 index 00000000..011ff88e --- /dev/null +++ b/tests/spec_fixtures/.gitattributes @@ -0,0 +1,3 @@ +# The vendored Spec files are byte-exact copies. Git must not touch line endings +# (the pin is a hash of the bytes; a CRLF checkout would break it). +* -text diff --git a/tests/spec_fixtures/PIN.toml b/tests/spec_fixtures/PIN.toml new file mode 100644 index 00000000..d282d156 --- /dev/null +++ b/tests/spec_fixtures/PIN.toml @@ -0,0 +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" + +# `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" + +# `git rev-parse :`, the git blob ID of each other vendored file. +[files] +"docs/experiment-v1.md" = "3e5bd59edfa9e9b2deeb0b480d03da8ed57bb9f2" +"tools/audit/refval.py" = "bff88079732a63fe5ba11dd9df5a7887348d0587" diff --git a/tests/spec_fixtures/README.md b/tests/spec_fixtures/README.md new file mode 100644 index 00000000..59c3a022 --- /dev/null +++ b/tests/spec_fixtures/README.md @@ -0,0 +1,35 @@ +# Vendored MorphoBricks-Spec files + +A partial copy of [MorphoBricks-Spec](https://github.com/CellMigrationLab/MorphoBricks-Spec) +at the commit named in `PIN.toml`, laid out as in the Spec repository: + +| Here | In the Spec | Used for | +|---|---|---| +| `fixtures/` | `fixtures/` | The conformance fixtures: every test of the experiment-description reader runs on them | +| `docs/experiment-v1.md` | `docs/experiment-v1.md` | The normative text; tests compare the reader's tables of codes, names and fields with it | +| `tools/audit/refval.py` | `tools/audit/refval.py` | The reference validator, used **only as a test oracle**; nothing in `src/` imports it | + +Rules (conformance plan, sections 1 and 2): + +- **Never edit these files.** `tests/test_spec_pin.py` hashes `fixtures/` with git's tree + algorithm and each other file with git's blob algorithm, and fails when one byte differs + or a file is added or missing. A fixture that a reader fails is a finding for the Spec + repository, not something to change here. +- **The pin is a full commit SHA**, never a branch. +- **A skipped Spec test is a failure.** Every test module named `tests/test_spec_*.py` is + held to it by `tests/spec_gate.py`, so a missing package, file or weight cannot turn a + conformance test into a silent pass. +- The reader has no copy of the rules besides its one table module + (`tests/test_spec_no_fallback.py`). + +## Updating the pin + +1. In a checkout of the Spec, check out the new commit and read + `git rev-parse HEAD`, `git rev-parse HEAD:fixtures`, + `git rev-parse HEAD:docs/experiment-v1.md` and `git rev-parse HEAD:tools/audit/refval.py`. +2. Replace the vendored files from that commit, for example + `git archive HEAD fixtures | tar -x -C tests/spec_fixtures`, and `git show HEAD:` for + the two single files. +3. Put the four values in `PIN.toml`. +4. Run the whole suite. The reader, its tables and its tests change in the same pull + request when the Spec changed text or fixtures (the Spec changes first). diff --git a/tests/spec_fixtures/docs/experiment-v1.md b/tests/spec_fixtures/docs/experiment-v1.md new file mode 100644 index 00000000..3e5bd59e --- /dev/null +++ b/tests/spec_fixtures/docs/experiment-v1.md @@ -0,0 +1,780 @@ +# MorphoBricks experiment description, schema version 1 (normative) + +- **Status:** Normative specification, Stage 0 (6 November 2026). No reader implements it yet. + The fixed-SHA audit of current consumers (roadmap S0.1) is done + ([PR #5](https://github.com/CellMigrationLab/MorphoBricks-Spec/pull/5), audit + [`s01-consumer-audit-2026-11-06.md`](audits/s01-consumer-audit-2026-11-06.md)); the owner answered + its questions AQ1-AQ13 on 6 November 2026 ([ADR 0001](adr/0001-experiment-description-v1.md), + third round) and this text applies them. A fourth decision of the same day (ADR 0001, fourth + round: folder labels are never restricted) gives conditions and technical repeats a canonical ID + separate from their source folder label (§4.6a). A statement about a consumer is as audited as that + audit; it is not a conformance claim for any brick. +- **Decisions:** [ADR 0001](adr/0001-experiment-description-v1.md). +- **Fixtures:** [fixtures/README.md](../fixtures/README.md). **Conformance:** + [conformance-plan.md](conformance-plan.md). + +The key words MUST, MUST NOT, SHOULD and MAY are normative. "Reader" means any program that +parses the file (in v1, preprocessing is the only reference reader); "writer" means any program +that produces it. + +## 1. Scope + +The file `morphobricks.toml` describes **what one preprocessing experiment is**: conditions, +canonical channels, biological-repeat pairing, technical-repeat acquisition layouts, mask +semantics and an optional primary reference per condition. It MUST NOT contain file inventory, +fingerprints, calibration, exclusions, per-FOV statements, axes, brick names or routing, feature +requests, normalization, statistics, histogram or model settings. Those stay in +`calibration.csv`, the run manifest, analysis requests and MorphoCoverage configuration. + +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. + +## 2. File, encoding and parsing + +1. The file name is `morphobricks.toml`, in the preprocessing output folder. It is written by the + preprocessing notebook/CLI under the output folder's lock, atomically, and never by `run`. + Readers MUST refuse a symlinked file (`R_FILE_SYMLINK`). +2. The bytes MUST be UTF-8 without a byte-order mark (`E_FILE_ENCODING`) and MUST be valid TOML + 1.0 (`E_TOML_SYNTAX`). A key declared twice, in any spelling TOML treats as the same key + (bare vs quoted, table vs dotted vs inline), MUST be refused as `E_DUPLICATE_KEY`. +3. Readers MUST NOT convert types. A value whose TOML type differs from §4 is `E_TYPE`. In + particular a TOML integer, float, boolean, date, time or datetime where text is required, a + string where an integer or boolean is required, and a boolean where an integer is required + (implementations whose booleans are integers MUST check the exact type). +4. Every table has a closed set of fields. A field not listed in §4 is `E_UNKNOWN_FIELD`, except + the names in §4.7, which are `E_FORBIDDEN_FIELD`. There is no extension namespace. +5. Comments are allowed and carry no meaning. Writers do not preserve them (§9). +6. The SHA-256 of the exact bytes read is the file's provenance identity (run manifest). It is not + a cache key (§9.3). +7. A reader has no source of rules other than the normative text and fixtures of the Spec commit it + pins. It MUST NOT carry a fallback copy or older variant of the rules, and MUST NOT obtain any + enumeration (in v1 only `representation`, §4.5) or vocabulary from outside the reader at run + time. An enumeration it cannot resolve is a refusal, never free text. + +## 3. Identities + +### 3.1 Kinds of identity + +| 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 | +| 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 | +| Image type | `image_type` | Declared by the user per imaging unit (§4.4b) | Enumeration | +| Channel ID | key of `channels` | Canonical | Token | +| Mask ID | key of `masks` | Canonical (object-set name) | Token + object set | +| Mask source type | `source_type` | Source (discovered mask type) | Label | +| Display name | `display_name` | Display only, never an identity | Non-empty text (§4.8) | + +### 3.2 Rule sets + +- **Label** (preprocessing decision D-e, `check_identity_text`, preprocessing v0.4.0 `bc7b872`): text; + no leading or trailing whitespace (nothing is trimmed: refused as written); not `""`, `NA`, `nan`, `none`, `null`, `n/a` or `` in any + case (`E_ID_TEXT`). +- **Token** (MorphoFeature `naming.py`, MorphoFeature v0.1.0 `182b373`): the Label rules, plus the + regular expression `^[A-Za-z][A-Za-z0-9]*(?:_[A-Za-z0-9]+)*$` (`E_ID_SYNTAX`; this also excludes + the feature-column separator `__`). The two checks are independent rules: an identity that breaks + both is reported with both codes (§6, one code per broken rule). +- **Reserved list** (one list, shared by channel IDs and mask IDs; AQ4). A channel ID or a mask ID + whose case-folded form (`str.casefold()`) is in the list is `E_ID_RESERVED`; `ring`, `Ring` and + `RING` are all refused. The list is closed: a new entry needs a new `schema_version`. + + | Names | Source | + |---|---| + | `whole`, `cell`, `nucleus`, `cytoplasm`, `ring`, `expanded_nucleus` | MorphoRelate compartment names; MorphoFeature `naming.py:44-59` (`182b373`) | + | `condition`, `biological_repeat`, `repeat`, `technical_repeat`, `position_id`, `image_file`, `image_path`, `mask_file`, `patch_file`, `patch_id`, `label_id`, `z`, `z0`, `z1`, `c`, `t` | MorphoRelate reserved column names, `registry/_expand.py:41-59` (MorphoRelate v0.1.1 `160ad23`; name rules `:68-115`) | + + The reserved list is not the forbidden-name list of §4.7, which is compared exactly and applies + to field names and ID keys. A name in both (`z`) is reported by the earlier phase only. Neither + list ever applies to a folder label: a `source_folder` or `source_repeat` value is never + restricted by them (§4.6a). +- **Object set** (MorphoFeature `is_reserved_object_set`): in addition to the reserved list, a mask + ID whose case-folded `_`-separated parts include `in` is `E_ID_RESERVED`. +- **RGB tokens:** `R`, `G` and `B` are the fixed channel IDs of RGB units (§4.4b); they are not on + the reserved list and are valid channel IDs, with the extra rules of §4.4b. +- **`single`:** an identity of any kind whose case-folded form is `single` (a source folder + included) MUST be exactly `single`. A technical repeat `single` MUST be the only technical + 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). + +### 3.3 Normalization, uniqueness and case + +- **NFC.** Identities (the keys and identity fields of §3.1, and every entry of `used_ids`) are + Unicode NFC. A writer MUST emit NFC. A reader MUST refuse an identity that is not NFC + (`E_ID_NOT_NFC`, reported for every such identity, including `reference` values, members' keys, + `source_folder` values, unit `channels` entries and `source_channels` entries). The check of a reference is NFC only and + belongs to phase 4, so a non-NFC channel reference is `E_ID_NOT_NFC` and never + `E_UNKNOWN_CHANNEL` (AQ13). A reader never transforms an identity: no normalization, trimming, case folding, parsing + or repair of any kind, and so it never resolves a reference through a transformed spelling. + Display names are not identities and are not checked (§4.8). A folder label read from disk that + is not NFC is refused by preprocessing (`R_FOLDER_NOT_NFC`), never normalized and never renamed; + two folders equal after NFC are therefore also refused, because one of them is not NFC. The + stored `source_folder` is the label exactly as read, so it is NFC by the same rule. +- Identities are compared exactly, code point by code point. +- **No repair.** No reader or writer ever repairs, re-cases, pads, trims or renumbers an + identity. A malformed, colliding or non-NFC ID is refused with its code and left as written. + There is no ID allocator: IDs have no width, prefix or number rule (`R999`, `R1000` and `cl001` + are valid and stay as they are). +- Within each of these scopes, two identities that differ after NFC but whose `str.casefold()` + forms are equal are `E_ID_CASE_COLLISION`: condition IDs; condition source folders; repeat IDs; + source repeats over the whole file; technical repeat IDs over the whole file; technical-repeat + source folders over the whole file; channel IDs; mask IDs; mask source types. + (Preprocessing `check_identity_case`: a label is spelled one way in the whole run.) Case folding + is never applied when resolving a reference (`ctrl` does not resolve to `CTRL`). +- Identities are never parsed. `rep1`, `2026-01-01`, `012` and `acquisition_2` are opaque text; + no number, date, order or meaning is derived from them. +- **Immutability and no reuse.** Once a repeat, condition, technical repeat or channel is + confirmed, its ID never changes (§4.6) and is never reused after removal (§4.9). Only + `display_name` may change, at any time. The `source_folder` of an existing ID is not freely + editable: a change is a reassignment (§4.6). + +### 3.4 Keys downstream: the source label stays, the canonical repeat ID is added (AQ5) + +- The existing FOV key (`condition`, `biological_repeat`, `technical_repeat`, `position_id`) is + **unchanged**. `biological_repeat` in it stays the **source repeat**, the source folder label. + FOV, patch and object identities, fitted-scaling keys, caches and exports keep their current + spelling; a consumer MUST NOT replace the source label by the canonical repeat ID in any of + them. +- The canonical repeat ID is **added** next to the key, as the field `repeat_group` (the column + name of the legacy design file, §11). Grouping and pairing MUST use `repeat_group`: pooling of + normalization and scaling, pairing of conditions and families in MorphoCoverage, and any + statement about biological N. A consumer MUST NOT group or pair by `biological_repeat` when a + description exists, and MUST NOT fall back to folder-name grouping. +- `repeat_group` is a function of the FOV key (a member belongs to exactly one repeat, + `E_SOURCE_REPEAT_REUSED`), so adding it creates no new identity: two FOVs with equal keys have + equal `repeat_group`. Products that pool over a repeat key their caches on the `repeats` section + digest (§9.3); products of one FOV keep keying on the FOV key. +- Consequence: `rep1` of `CTRL` and `rep1` of `DrugA` may be different repeats (`v30`) and the same + source label may be in two repeats under different conditions; the key stays unique because it + includes the condition. +- **Conditions and technical repeats follow the same pattern (fourth round).** In the existing + 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). + 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 + reference by the source folder label when a description exists. + +## 4. Tables and fields + +Types are TOML types. "Text" is a TOML string. "Date" is a TOML **string** `YYYY-MM-DD` naming a +real calendar day (`E_DATE_FORMAT`); a TOML date literal is `E_TYPE`. "Non-empty text" refuses +`""` and whitespace-only strings (`E_TEXT_EMPTY`). "Req" = required; a missing required field is +`E_MISSING_FIELD`. An empty table or array where at least one entry is required is `E_EMPTY`. + +### 4.1 Top level + +| Key | Type | Req | Rule | +|---|---|---|---| +| `schema` | text | yes | Exactly `morphobricks-experiment` (`E_SCHEMA_ID`) | +| `schema_version` | integer | yes | Exactly `1` (missing: `E_SCHEMA_VERSION_MISSING`; other integer: `E_SCHEMA_VERSION_UNSUPPORTED`) | +| `conditions` | table of condition | yes | ≥ 1 entry | +| `channels` | table of channel | yes | ≥ 1 entry | +| `repeats` | table of repeat | yes | ≥ 1 entry | +| `masks` | table of mask | no | May be absent or empty | +| `used_ids` | table of ID lists | yes | Append-only registry of every ID ever confirmed, per kind (§4.9) | + +### 4.2 `conditions.` + +| 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) | +| `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) | +| `treatment_key` | non-empty text | no | Descriptive plain string, e.g. the same perturbation under different backgrounds (§4.2a) | + +Every condition MUST be the condition of at least one member (`E_CONDITION_WITHOUT_MEMBERS`). + +**§4.2a Descriptive only.** `target` and `treatment_key` are descriptive free text, written and +shown as typed. They are not identities: no Label or Token rule, no case-collision scope, no +uniqueness (two conditions may carry the same value, or values differing only in case). A consumer +MUST NOT use either for grouping, pairing, joins, contrasts, family definitions or any cache key +(§9.3 omits them from every digest). A future use as a grouping key needs a new `schema_version` +that makes it an identity. + +**Prefill only (AQ3).** A consumer MAY offer the values as a *suggestion* to prefill an input of +its own, for example MorphoCoverage's reagent grouping. The suggestion is shown to the user, who +confirms or edits it **inside that consumer**; the confirmed grouping is the consumer's own input, +recorded in its own configuration, and is the only thing the analysis reads. An unconfirmed +prefill is never used, and a later edit of `target` or `treatment_key` in the file changes no +confirmed grouping, no digest and no cache key. + +### 4.3 `channels.` + +| Key | Type | Req | Rule | +|---|---|---|---| +| `display_name` | non-empty text | no | Display only (§4.8), e.g. `DAPI` | +| `header_names` | array of non-empty text | no (default `[]`) | Header channel names the user confirmed as evidence for this channel; stored as typed (not identities: no Label, Token or NFC check; outer whitespace is part of the name); no duplicates, where two entries equal after NFC are duplicates (`E_DUPLICATE_VALUE`); order not semantic. Not allowed for `R`, `G`, `B` (§4.4b) | + +A channel that appears in no technical repeat's `channels` is **valid**; the reader reports the warning `W_UNUSED_CHANNEL`. A declared but unused channel is never an error, and never an implicit zero or default. + +Header evidence (enforced by preprocessing, §8): a non-empty header channel name read at source +index *i* of an image in a unit MUST match a member of `header_names` of the channel at position +*i* of that unit's list, else `R_HEADER_CONTRADICTION`. **Matching rule (AQ7):** the name read from +the header and the `header_names` entry are each converted to NFC, and the results are compared +code point by code point. Nothing else is applied: **no trimming** (outer whitespace is part of +the name, so a header ` DAPI` matches only an entry ` DAPI`), no case folding, no similarity. The +NFC form is a comparison key only; it is never stored, and the header name is recorded as read. +Any other spelling needs a user-confirmed alias (a new `header_names` entry). Preprocessing +therefore MUST stop trimming header channel names (it trims today, `channel_names_trimmed`, +preprocessing `zarr_input.py:68`): this is a Stage 1 change and the raw name is what is recorded +and compared. This holds even when the header +name equals the channel ID or display name: aliases are only ever user-confirmed, never inferred from +name similarity, case or pixels. An image without header names needs no evidence. The same header +name MAY be evidence for different channels (fluorophores are reused across acquisitions). + +### 4.4 `repeats.` + +| Key | Type | Req | Rule | +|---|---|---|---| +| `display_name` | non-empty text | no | Display only (§4.8) | +| `confirmed` | boolean | yes | MUST be `true` in a published file (`E_REPEAT_UNCONFIRMED`): the N35 answer "prepared and acquired under the same circumstances" | +| `confirmed_on` | date | yes | Date of the user's confirmation (§4.4a) | +| `members` | table of member | yes | ≥ 1 entry | + +`repeats..members.` (one member = one source biological-repeat folder of one +condition; at most one per condition per repeat, guaranteed by TOML key uniqueness): + +| Key | Type | Req | Rule | +|---|---|---|---| +| `source_repeat` | text | yes | Exact source folder label (Label rules) | +| `image_count` | integer ≥ 1 | yes | Images to analyse at confirmation (N35 evidence; `E_VALUE_RANGE`) | +| `technical_repeats` | table of technical repeat | yes | ≥ 1 entry | + +`repeats..members..technical_repeats.` (one imaging unit): + +| Key | Type | Req | Rule | +|---|---|---|---| +| `source_folder` | text | yes | Exact technical-repeat folder label as read from disk, or `single` when the member has no such 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 | +| `channels` | array of channel IDs | yes | ≥ 1 entry; ordered: entry *i* is source channel C*i*; every entry declared (`E_UNKNOWN_CHANNEL`); no repeats (`E_DUPLICATE_CHANNEL_IN_UNIT`) | +| `image_type` | text | no | Only the value `rgb` (`E_ENUM`: exact, lower case); absent = an ordinary channel image. User-declared, never inferred (§4.4b) | + +**§4.4b RGB imaging units (AQ6; supersedes the D8 refusal).** RGB (interleaved colour) is an +accepted, **declared** image type, per imaging unit: + +- A unit with `image_type = "rgb"` MUST list exactly `["R", "G", "B"]`, in this order, as its + `channels` (source samples 0, 1, 2): any other count, order, case or IDs is + `E_RGB_UNIT_CHANNELS`. +- `R`, `G`, `B` are fixed tokens. A unit without `image_type = "rgb"` MUST NOT list a channel whose + case-folded ID is `r`, `g` or `b` (`E_RGB_TOKEN_MISUSE`). +- They have **no aliases**: a channel whose case-folded ID is `r`, `g` or `b` MUST NOT have a + non-empty `header_names` (`E_RGB_ALIAS`). `display_name` is allowed (display only). +- The image type is declared by the user and is **never inferred** from pixels, dtype, channel + count or file name. Preprocessing checks it against the image **header metadata** only (the + colour interpretation and samples per pixel that the format states, for example TIFF + photometric interpretation or OME `Interleaved`), in both directions: a unit declared `rgb` whose + header says it is not, or a unit not declared `rgb` whose header says it is, is + `R_IMAGE_TYPE_CONTRADICTS_HEADER`. A format whose header states no colour interpretation cannot + contradict the declaration, which then stands on user confirmation and is recorded in the + manifest as not header-verified. +- The spec gives RGB **no scientific meaning**: no grey conversion, luminance, stain separation + or joint-channel rule. Each brick owns the meaning and MUST declare, in its own documented + capabilities, whether it supports RGB; undeclared means unsupported. An analysis request that + includes an RGB unit's channels for a brick without RGB support is refused at analysis + (`A_RGB_NOT_SUPPORTED`, §8), never converted or split into grey channels silently. +- A file MAY hold RGB and non-RGB units; their lists differ, so that layout is heterogeneous + (`R_HETEROGENEOUS_LAYOUT_NOT_ENABLED` in Stage 1-2). A unit's `image_type` is part of the + `repeats` section digest (§9.3). + +**§4.4a `confirmed_on` is a user fact.** It is the calendar date of the user's confirmation +action. A writer MUST NOT default, infer or refresh it (not from the write time, the read time, +file times, another repeat or a template); a reader supplies no default (absent is +`E_MISSING_FIELD`). A reconciliation that does not reopen the N35 confirmation (§10) MUST keep +`confirmed_on` of every unchanged repeat byte-identical; a candidate that changes it without a +reopening and a new user confirmation is refused (`R_CONFIRMED_ON_CHANGED`). + +Repeat rules: + +- The member key MUST be a declared condition ID (`E_UNKNOWN_CONDITION`). +- A source folder `(condition, source_repeat)` MUST belong to exactly one repeat + (`E_SOURCE_REPEAT_REUSED`). +- If a canonical repeat ID R equals, ignoring case, the `source_repeat` S of a member, that member + MUST belong to R (`E_REPEAT_ID_SHADOWS_SOURCE`). This forbids swapped labels such as repeat + `rep1` holding folder `rep2` while repeat `rep2` holds folder `rep1`. +- One folder, one ID (§4.6a): a condition `source_folder` belongs to exactly one condition ID and + a technical-repeat `source_folder` to exactly one technical repeat ID over the whole file + (`E_SOURCE_FOLDER_REUSED`); one technical repeat ID carries the same `source_folder` in every + unit that declares it (`E_SOURCE_FOLDER_CONFLICT`). +- A repeat MAY lack some conditions (warning `W_REPEAT_MISSING_CONDITIONS`). +- Technical repeats inside one biological repeat MAY have different channel counts and orders. + They remain one biological repeat; they never create additional biological replicates. + +### 4.5 `masks.` + +| Key | Type | Req | Rule | +|---|---|---|---| +| `source_type` | text | yes | Exact discovered mask type (Label rules); one mask per source type (`E_MASK_SOURCE_TYPE_REUSED`) | +| `representation` | text | yes | `instances` or `binary` (`E_ENUM`); user-confirmed, never inferred from dtype, values or names. A mask declared `binary` with more than two distinct values is refused by preprocessing's mask step (`R_MASK_CONTRADICTS_REPRESENTATION`, §4.5a); the user must declare `instances` | +| `source_channels` | array of channel IDs | no | Provenance only (what the mask was derived from); not a restriction on measured channels; each declared (`E_UNKNOWN_CHANNEL`); no duplicates (`E_DUPLICATE_VALUE`); order not semantic | +| `display_name` | non-empty text | no | Display only (§4.8) | + +Mask files, pairing, completeness and fingerprints stay in the run manifest. A declared mask with +files for none, some or all FOVs is valid (progressive segmentation, 0/1000 → 1000/1000). + +**§4.5a When the representation is checked (AQ10).** `R_MASK_CONTRADICTS_REPRESENTATION` needs +mask pixels. It is checked **in the preprocessing mask step** (the step that reads mask pixels), +for every mask file as it is added: for a progressive mask, **for each new batch**, on the new +files. Publishing `morphobricks.toml` MUST NOT read mask pixels; it records, per mask and file +set in the run manifest, the status `checked` (with the checked files and their fingerprints) or +`not yet checked`. A run that uses a mask file whose status is `not yet checked` MUST refuse +(`A_MASK_UNCHECKED`, §8); it never checks implicitly and never assumes. A new batch of files is +`not yet checked` until the mask step has read it. Pixels cannot prove `instances` (an integer +mask holding only 0 and 1 is accepted as declared). + +**§4.5b Grouped (`parent_labels`) masks are out of scope in v1 (AQ8).** MorphoRelate's third +representation `parent_labels` (preprocessing kind `grouped`) has no v1 value: +`representation = "parent_labels"` is `E_ENUM`. A grouped mask cannot be declared `instances` +(wrong objects) or `binary` (more than two values: `R_MASK_CONTRADICTS_REPRESENTATION`), so it +is not described, and not analysed through a description, in v1. A design for it follows with a +later `schema_version` (v1.1); nothing here predicts its form. + +**§4.5c A binary mask is never an object set by default (AQ9).** A request that uses a `binary` +mask as an object set is `A_MASK_NOT_INSTANCES`. The only way around it is an **explicit, +per-request, recorded conversion** to instances chosen in the request, for example CellPatcher +`binary_mask_policy="connected_components"`: it is recorded in the run manifest with the mask ID, +the method and its parameters, applies to that request only, and is never automatic, never a +default and never stored in `morphobricks.toml`, where `representation` stays `binary` +(`binary_mask_policy` and `mask_conversion` are `E_FORBIDDEN_FIELD`, §4.7). The numerical method +stays in the brick that owns it. + +### 4.6 Immutable IDs + +- Once confirmed, the ID of a repeat, condition, technical repeat or channel MUST NOT change. + There is no rename and no retired-ID table. A rename in the file would be a different ID for the + same data. +- **What a changed entity is (AQ1).** An entity is what the published file says an ID holds: for a + repeat, its member folders `(condition, source_repeat)`; for a condition, its `source_folder` + and its source repeat folders over all repeats; for a technical repeat, its `source_folder` and + the members `(condition, source_repeat)` that declare it; for a channel, its positions `(condition, source_repeat, technical + repeat, index)` and its `header_names`. An entity is *changed* only when something it held in + the published file is **removed or reassigned**. **Adding** does not change it: a new member + folder joining an existing repeat, a new repeat folder of an existing condition, a new unit + of an existing technical repeat ID, a new unit or position of a channel, a new alias, is not + `R_CONFIRMED_ID_CHANGED`. (It may reopen the N35 + confirmation, §10; reopening is not an ID change.) +- Preprocessing MUST refuse, as `R_CONFIRMED_ID_CHANGED`, a candidate file (compared with the + published file it was drafted from, §10) in which (a) a repeat with the same member folders + `(condition, source_repeat)` carries another ID, (b) a channel's ID is replaced by another ID at + every unit position it held with the same header evidence, (b2) a condition or technical repeat + with the same `source_folder` carries another ID, or (c) an ID that is declared in both + files names a changed entity (some member folder, position or alias it held is removed or + reassigned, or its `source_folder` is another folder). Removing or reassigning is accepted only with an explicit, recorded user + confirmation (the same record as for a mistyped ID, below); the confirmation of a removal that + §10 requires is that record. An ID that was removed and comes back is `R_USED_ID_REUSED` + (§4.9). A condition or technical repeat ID is not its folder label (§4.6a): a folder that is + 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. +- **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 + `ActinStain`) with the recorded re-confirmation above, and `display_name` (here `Actin`) carries + the appearance. The old spelling stays listed and is never reused. +- The friendly name of any entity is `display_name` (§4.8), not the ID. +- **Publication reports a set (AQ2).** When several `R_*` refusals apply to one candidate, + preprocessing reports **every** applicable code, not the first (`t02` is + `R_CONFIRMED_ID_CHANGED` and `R_USED_ID_DROPPED`). + +### 4.6a Folder labels and canonical IDs (fourth round) + +Conditions and technical repeats are identified exactly like biological repeats: the key is a +**canonical ID** (immutable, user-confirmed, the only thing identity uses), and the folder it was +discovered under is recorded separately in the required field `source_folder`. + +- **`source_folder` is a source value, not an identity.** It holds the folder label exactly as + read from disk (Label rules, NFC; never trimmed, normalized or renamed). It is never a table + key, a `reference` value, a member key or a `used_ids` entry, and no reader resolves an ID + through it. +- **Folder labels are never restricted by the reserved list (§3.2) or the forbidden names + (§4.7).** Any folder label that satisfies the Label rules and NFC may be a `source_folder`, + including `control`, `files` or `z`. The restrictions stay on the **IDs** (keys), where the + forbidden names would collide with fields. +- **One folder, one ID.** Over the whole file a condition `source_folder` names exactly one + condition ID, and a technical-repeat `source_folder` exactly one technical repeat ID + (`E_SOURCE_FOLDER_REUSED` when two IDs of one kind share a folder; `E_SOURCE_FOLDER_CONFLICT` + when one technical repeat ID is declared with two different folders). A condition has one + folder; a technical repeat ID has the same folder in every unit that declares it. Folders that + differ only in case are `E_ID_CASE_COLLISION` (§3.3), as before. +- **Proposed ID.** When preprocessing meets a new folder it proposes the folder label as the ID + if that is allowed: it passes the Label rules, is not an exact forbidden name (§4.7), and does + not collide (exactly or by case folding) with a declared or `used_ids` ID of its kind. If it + is not allowed (for example the folder `control`), preprocessing proposes a different ID that + 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). +- **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). +- 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. + +### 4.7 Forbidden fields + +These names are refused as `E_FORBIDDEN_FIELD` in **any** table, with a message naming where the +information belongs, and they apply both to **field names** and to **ID keys** (the key of a +condition, channel, repeat, member, technical repeat or mask; AQ13). The comparison is exact and +case-sensitive. A forbidden name is reported once, at its name; its value is not examined. A +source folder label equal to one of these names (a condition folder `control`, a technical-repeat +folder `files`) cannot be used as the **ID** (the key) but **is** describable: it is stored as the +`source_folder` value, which is never restricted (§4.6a), and the user confirms another ID +(`ctrl`, `acq1`) that preprocessing proposes. The folder is never renamed on disk. A +`source_repeat` value is not a key and was never affected. + +| Field | Belongs to | +|---|---| +| `axes`, `shared`, `plane_scope`, `z`, `projection` | Image/manifest records; v1 is 2-D only (preprocessing #173, #176) | +| `pixel_size`, `calibration` | `calibration.csv` | +| `fovs`, `files`, `paths`, `overrides`, `exclude`, `fingerprints` | Run manifest / preprocessing inputs; no per-FOV statements | +| `bricks`, `routes`, `features`, `normalization`, `statistics`, `contrasts` | Analysis requests / brick and MorphoCoverage configuration | +| `binary_mask_policy`, `mask_conversion` | The analysis request that records a per-request binary-to-instances conversion (§4.5c) | +| `control`, `roles`, `experiment` | Derived from `reference` / analysis requests / MorphoCoverage FDR families | +| `source_index`, `channel_count` | Implied by the order and length of `channels` | +| `retired_repeats`, `replaced_by` | None: IDs are immutable (§4.6); the only registry is `used_ids` (§4.9), IDs only and never a mapping | +| `labchronicle`, `provenance`, `source_database` | Run manifest provenance (preprocessing, §9.3): lab-tool links are never part of this file | + +### 4.8 Display names + +`display_name` (conditions, repeats, technical repeats, channels, masks) is for display only (reports, notebooks, +plots). It MUST NOT be used for identity, joins, pairing, references or cache keys, and changing +or adding it invalidates nothing (§9.3). A consumer MUST NOT accept a display name where an ID is +expected, even when the two strings are equal and even when the display name is unique: a +`reference`, member key or unit channel entry that names a display name is refused with the code +of an unknown ID (`E_REFERENCE_UNKNOWN`, `E_UNKNOWN_CONDITION`, `E_UNKNOWN_CHANNEL`). + +Display names are kept exactly as typed: never normalized, trimmed or case-changed on read or +write. They are unique within one kind (conditions, repeats, technical repeats, channels, masks; +the same string may appear in two kinds, and a display name may equal a `source_folder`): two +display names of one kind whose keys are equal are `E_DISPLAY_NAME_COLLISION`; for technical +repeats the names of one technical repeat ID in several units never collide with each other, only +with those of another ID, and need not agree; where the key of a name *x* is `NFC(casefold(NFC(x)))` with +`str.casefold()` (so `Control` and `control`, `Straße` and `STRASSE`, decomposed and composed +`Café` collide). The key is used for the comparison only and is never stored or used for lookup. + +### 4.9 Used IDs + +`used_ids` is an append-only registry of IDs, per kind. It holds IDs only: never a mapping, a +date or a reason. + +| Key | Type | Req | Rule | +|---|---|---|---| +| `conditions` | array of text | yes | Every condition ID ever confirmed | +| `channels` | array of text | yes | Every channel ID ever confirmed | +| `repeats` | array of text | yes | Every canonical repeat ID ever confirmed | +| `technical_repeats` | array of text | yes | Every canonical technical repeat ID ever confirmed (`single` included) | + +- Entries follow the identity rules of their kind (Label for conditions, repeats and technical + repeats, Token for channels; NFC, §3.3), have no duplicates (`E_DUPLICATE_VALUE`), and share the case-collision + scope of the kind's declared IDs (`E_ID_CASE_COLLISION`): a retired `ctrl` cannot come back as + `CTRL`. At least one entry per kind (`E_EMPTY`). +- Source folder labels are not IDs and are never listed. Every declared ID of a kind MUST be + listed (`E_USED_ID_MISSING`). A listed ID that is not + declared is a **retired** ID: its entity was removed. +- Publication MUST refuse a candidate that (a) omits an ID listed in the published file it was + drafted from (`R_USED_ID_DROPPED`: removing an entity keeps its ID listed), or (b) declares as + an entity an ID that is listed but not declared there (`R_USED_ID_REUSED`). Rule (b) applies + even when the same source folder returns: it needs a new ID. The registry carries no mapping, + so it cannot say which entity an ID once named. A case-only correction therefore needs a new, + non-colliding ID (§4.6). +- Scope of the guarantee: the published file's lineage inside one output folder (§10 compares each + publication with the file it replaces). A new output folder starts a new lineage and the + registry cannot see across it; the run manifest history is the only cross-folder record + (ADR 0001, decision Q1). +- `used_ids` is not part of any section digest (§9.3). + +## 5. Reference graph + +- `reference` names the primary matched control of a condition. It is experiment metadata, not + a contrast table; further contrasts belong to MorphoCoverage. +- It MUST name a declared condition, exactly (`E_REFERENCE_UNKNOWN`; `ctrl` does not resolve to + `CTRL`), MUST NOT name its own condition (`E_REFERENCE_SELF`), and the graph of all `reference` + edges MUST be acyclic (`E_REFERENCE_CYCLE`). A condition has at most one `reference` (an array + is `E_TYPE`). +- Chains are allowed (`VEC_SiCol6a1 → VEC_SiNC → VEC`). A condition is a *control* iff some + condition references it. +- If a condition and its reference share no repeat, MorphoCoverage cannot pair them: warning + `W_REFERENCE_UNPAIRED`. + +## 6. Validation phases and error catalogue + +A reader MUST run the phases in order and stop after the first phase that reports an error. It +MUST report every error of that phase, each with the code, the table path (e.g. +`repeats.Day1.members.CTRL.technical_repeats.single.channels[2]`) and the offending value. +Warnings are reported only for a file without errors and never block publication. Conformance +compares the **set of codes** of the reported phase ([fixtures](../fixtures/README.md)). +**One code per broken rule (AQ13):** each rule has its own code and a reader reports every rule +that an identity or value breaks, so ` DNA` as a channel ID is `E_ID_TEXT` (outer whitespace) and +`E_ID_SYNTAX` (the regular expression), and a decomposed key is `E_ID_NOT_NFC` in addition to any +other rule it breaks. A rule is never reported twice under two codes, and a reference is checked +for NFC only (phase 4) before it is resolved (phase 5). + +| Phase | Code | Meaning | +|---|---|---| +| 1 Decode | `E_FILE_ENCODING` | Not UTF-8, or starts with a byte-order mark | +| 1 | `E_TOML_SYNTAX` | Not TOML 1.0 (includes unquoted text such as `NO`, and `012`) | +| 1 | `E_DUPLICATE_KEY` | A key or table declared twice | +| 2 Header | `E_SCHEMA_ID` | `schema` missing or not `morphobricks-experiment` | +| 2 | `E_SCHEMA_VERSION_MISSING` | `schema_version` missing | +| 2 | `E_SCHEMA_VERSION_UNSUPPORTED` | `schema_version` an integer other than 1 | +| 2 | `E_TYPE` | `schema` or `schema_version` of the wrong TOML type | +| 3 Structure | `E_UNKNOWN_FIELD` | Field not defined for its table | +| 3 | `E_FORBIDDEN_FIELD` | Field name or ID key from §4.7 | +| 3 | `E_MISSING_FIELD` | Required field absent | +| 3 | `E_TYPE` | Wrong TOML type (no conversion) | +| 3 | `E_EMPTY` | Required table/array is empty | +| 3 | `E_TEXT_EMPTY` | Non-empty text is empty or whitespace only | +| 3 | `E_ENUM` | Value outside its enumeration | +| 3 | `E_DATE_FORMAT` | Not a real `YYYY-MM-DD` date string | +| 3 | `E_VALUE_RANGE` | Integer outside its range | +| 3 | `E_REPEAT_UNCONFIRMED` | `confirmed = false` in a published file | +| 4 Identity | `E_ID_TEXT` | Label rule violated | +| 4 | `E_ID_SYNTAX` | Token regular expression violated | +| 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) | +| 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 | +| 5 | `E_DUPLICATE_VALUE` | Duplicate in `header_names` (equal after NFC), `source_channels` or a `used_ids` list | +| 5 | `E_RGB_UNIT_CHANNELS` | A unit with `image_type = "rgb"` whose `channels` is not exactly `["R", "G", "B"]` (§4.4b) | +| 5 | `E_RGB_TOKEN_MISUSE` | A unit without `image_type = "rgb"` lists a channel whose case-folded ID is `r`, `g` or `b` (§4.4b) | +| 5 | `E_RGB_ALIAS` | A channel whose case-folded ID is `r`, `g` or `b` has non-empty `header_names` (§4.4b) | +| 5 | `E_DISPLAY_NAME_COLLISION` | Two display names of one kind equal after NFC and case folding (§4.8) | +| 5 | `E_USED_ID_MISSING` | A declared condition, channel, repeat or technical repeat ID is not in `used_ids` (§4.9) | +| 5 | `E_SOURCE_FOLDER_REUSED` | One `source_folder` on two condition IDs, or on two technical repeat IDs (§4.6a) | +| 5 | `E_SOURCE_FOLDER_CONFLICT` | One technical repeat ID declared with two different `source_folder` values (§4.6a) | +| 5 | `E_CONDITION_WITHOUT_MEMBERS` | Declared condition in no repeat | +| 5 | `E_SOURCE_REPEAT_REUSED` | One source folder in two repeats | +| 5 | `E_REPEAT_ID_SHADOWS_SOURCE` | Repeat ID equal to the source folder name of another repeat's member | +| 5 | `E_MASK_SOURCE_TYPE_REUSED` | Two masks with one `source_type` | +| 5 | `E_REFERENCE_UNKNOWN` | `reference` names no declared condition ID (a display name is not an ID) | +| 5 | `E_REFERENCE_SELF` | `reference` names its own condition | +| 5 | `E_REFERENCE_CYCLE` | The reference graph has a cycle | +| warn | `W_UNUSED_CHANNEL` | A declared channel is in no unit | +| warn | `W_REPEAT_MISSING_CONDITIONS` | A repeat lacks some declared conditions | +| warn | `W_REFERENCE_UNPAIRED` | A condition and its reference share no repeat | + +Phase-2 `E_TYPE` concerns only `schema`/`schema_version`. An unsupported or missing version stops +validation: a reader MUST NOT try to interpret fields of a version it does not support. + +## 7. Versioning and migration + +- `schema_version` is an integer. Any change to fields, types, rules or meanings requires a new + version. Readers MUST refuse any version they do not implement (`E_SCHEMA_VERSION_UNSUPPORTED`) + with a message naming the supported versions; they MUST NOT read a newer file "best effort". + The supported set is a constant of the reader (in v1: `{1}`). It is checked without network + access, against nothing fetched. A missing version is `E_SCHEMA_VERSION_MISSING` and is never + mapped to a default version. +- A migration between versions MUST be an explicit, recorded operation that writes a new file and + never changes canonical identities silently. +- **Repeat, condition, technical repeat and channel IDs are immutable** (§4.6) and never reused + (§4.9). There is no rename migration; a friendlier name is `display_name`. A mistyped ID is corrected only by a + recorded re-confirmation, which retires the old ID in `used_ids`. No reader or writer repairs, + re-cases or renumbers an ID (§3.3). +- **Source folder renamed or moved:** a removed member plus a new, unpaired, pending member. It is + never auto-paired with the removed one; the user may assign it to the existing canonical repeat + (the canonical ID does not change). For a **condition** or **technical repeat** folder the same + holds (the folder is removed, the new folder is pending with a proposed ID, §4.6a); giving the + new folder to the existing ID changes that ID's `source_folder`, which is a reassignment and + follows the removed/reassigned rules of §4.6 (recorded user confirmation, else + `R_CONFIRMED_ID_CHANGED`). The ID never changes. +- **Legacy `experiment_design.csv` → v1:** read with the strict reader (no `yes`/`no`/empty + coercion). `repeat_group` → canonical repeat ID; `biological_repeat` → `source_repeat`; + `image_count`, `confirmed`, `confirmed_on` copied (see the next point for a group whose rows + have different dates); the legacy `condition` label becomes the condition's `source_folder`, + and the condition ID is proposed as that label when allowed (§4.6a), else another proposed ID + the user confirms; technical repeats (folder, `source_folder`, ID) and channel lists come from + discovery and user confirmation. Controls: 0 marked → no `reference`; 1 marked → every other + condition references it; ≥ 2 marked → a draft with unresolved references that cannot be + cannot be published until the user answers (no name-stem guessing). A legacy design that violates v1 + rules (e.g. `E_REPEAT_ID_SHADOWS_SOURCE`) MUST be refused with the code, not repaired. A legacy + condition label on the reserved or forbidden lists is no longer a violation: it is the + `source_folder` and the user confirms another ID. +- **Legacy groups with different `confirmed_on` (AQ12).** v1 has one date per repeat. A legacy + `repeat_group` whose rows carry different dates takes the **latest** date (what preprocessing's + `ExperimentDesign.confirmed_on` returns today, `design.py:216-218`) and the migration records a + note listing each folder's original date. It never takes the clock. Consequently the + byte-identity of the derived `experiment_design.csv` (§11) is guaranteed **only** for repeat + groups with a single `confirmed_on`; for a group with several dates the derived rows carry the + latest date and only the note keeps the original per-row dates. A group with no dated row + cannot be published until the user supplies the date of their confirmation. + +## 8. Validation boundaries + +| Layer | Owns | Codes | +|---|---|---| +| Spec (this document) | Syntax, types, identities, cross-references, graphs; no files read except this one | `E_*`, `W_*` | +| Preprocessing (reference reader) | Physical agreement of the file with the experiment folder; reconciliation; publication | `R_*` | +| Analysis layer (MorphoFeature, MorphoRelate, fiberfeatures, MorphoEmbed, MorphoCoverage, CellPatcher, normalization) | Applicability of a request to the described units | `A_*` | + +Required preprocessing refusals (`R_*`): + +| Code | Meaning | +|---|---| +| `R_FILE_SYMLINK` | `morphobricks.toml` is a symlink | +| `R_CONFIRMED_ID_CHANGED` | A confirmed ID was changed, or a declared ID names a changed entity (something it held was removed or reassigned, a `source_folder` included; adding is not a change), without a recorded re-confirmation (§4.6) | +| `R_USED_ID_REUSED` | A candidate declares an ID that `used_ids` lists as retired (§4.9) | +| `R_USED_ID_DROPPED` | A candidate omits an ID listed in `used_ids` of the file it replaces (§4.9) | +| `R_CONFIRMED_ON_CHANGED` | `confirmed_on` of a repeat whose confirmation was not reopened differs from the published file (§4.4a) | +| `R_PUBLISHED_FILE_CHANGED` | The SHA-256 of the published file at publication differs from the draft's recorded base (§10) | +| `R_DRAFT_BASE_MISSING` | A draft has no recorded base SHA-256 (§10) | +| `R_FOLDER_NOT_NFC` | A folder label or a discovered mask type name read from disk is not NFC (§3.3) | +| `R_UNIT_NOT_FOUND` | A declared imaging unit has no included image | +| `R_UNIT_NOT_DECLARED` | A discovered imaging unit is not in the file (reconciliation draft required) | +| `R_CHANNEL_COUNT_MISMATCH` | An image's channel count differs from its unit's list length | +| `R_UNIT_NOT_HOMOGENEOUS` | FOVs of one unit differ in channel count, image type, interpreted axes or header names at one C index; the message names FOV, path and C index | +| `R_HEADER_CONTRADICTION` | A header name at C*i* is not, after NFC of both sides and with no trimming (§4.3), in the confirmed `header_names` of the unit's channel at *i* | +| `R_IMAGE_TYPE_CONTRADICTS_HEADER` | A unit's declared image type (`rgb` or absent, §4.4b) contradicts the image header metadata, in either direction; pixels are never examined | +| `R_HETEROGENEOUS_LAYOUT_NOT_ENABLED` | Stage 1–2 only: the units do not all have the same ordered list; names the units; lifted at Stage 3 | +| `R_MASK_TYPE_NOT_DECLARED` | A discovered mask type has no `masks` entry | +| `R_MASK_TYPE_NAME_INVALID` | A discovered mask type name breaks the Label rules (for example `NA`, ` cells`), so it cannot be a `source_type`; refused at discovery (AQ11) | +| `R_MASK_CONTRADICTS_REPRESENTATION` | A mask declared `binary` holds more than two distinct values; raised by the mask step on each new batch, not at publication (§4.5a) | + +Within one unit, `R_UNIT_NOT_HOMOGENEOUS` is reported first and replaces +`R_CHANNEL_COUNT_MISMATCH` and `R_HEADER_CONTRADICTION` for that unit: intra-unit discrepancies +are detected before channel identity is checked. + +Undetectable by any layer: identical-shape, unnamed images with physically swapped fluorophores. +Representative previews and explicit confirmation are the guard; image-based checks are advisory. + +Required analysis-layer refusals (`A_*`): `A_SCOPE_INCOMPLETE` (a requested canonical channel or +mask is absent from part of the requested scope; refuse unless the user selects and records an +eligible subset; never drop FOVs, impute, re-weight technical repeats or change biological N); +`A_MASK_NOT_INSTANCES` (a `binary` mask requested as an object set without the explicit recorded per-request conversion of §4.5c); `A_MASK_UNCHECKED` (a run uses a mask file whose representation check is `not yet checked`, §4.5a); `A_RGB_NOT_SUPPORTED` (a request includes the channels of an RGB unit, §4.4b, for a brick that does not declare RGB support); `A_DRAFT_REFUSED` (§10: `RunInputs` given a draft or an unconfirmed file). Each biological repeat is +classified **fully represented**, **partially represented** or **unavailable** per request and +recorded in the run manifest. Consumers resolve channels by canonical ID per unit, never by C +index alone, and group by canonical repeat ID (`repeat_group`, §3.4) while keeping the source label +in every key. + +## 9. Serialization, round trip and caching + +### 9.1 Canonical serialization (writers) + +Writers MUST produce deterministic bytes: UTF-8, no BOM, LF, a final newline, no comments. +Order: `schema`, `schema_version`, then `conditions`, `channels`, `repeats`, `masks` (an empty +optional table is omitted), `used_ids` (kinds in the order `conditions`, `channels`, `repeats`, +`technical_repeats`; each array sorted in code point order, inline); identities are NFC. Entries of each table sorted by key in code +point order; fields in the order of §4; each table as a standard `[header]`; arrays inline; +`header_names`, `source_channels` and `used_ids` arrays sorted, unit `channels` in declared order; keys bare when +they match `[A-Za-z0-9_-]+`, else basic quoted strings; strings as TOML basic strings with the +minimal escapes. + +### 9.2 Round trip + +For every valid file F: `parse(write(parse(F)))` MUST equal `parse(F)`, and `write` MUST be +idempotent. Any field a reader would drop is refused instead (§2.4), so no confirmed semantics can +be lost silently. Reordering tables or entries, or switching between bare/quoted keys or +standard/inline/dotted tables, never changes meaning. + +### 9.3 Provenance vs cache keys + +The whole-file SHA-256 is provenance. Preprocessing SHOULD also record per-section digests (SHA-256 +of the canonical serialization of `conditions`, `channels`, `repeats`, `masks`) so that consumers +key derived products only on the sections they use. `source_folder` is part of the `conditions` and +`repeats` digests (it fixes which folder an ID names; a reassignment changes them). Digests are computed with every `display_name`, +`target` and `treatment_key` omitted (descriptive fields are never cache keys, §4.2a), and +`used_ids` is not a section: a change to any of them MUST NOT change any digest, a change to `reference` MUST NOT +invalidate pixel-derived products; a change to a unit's channel list MUST invalidate the products +that depend on that unit's channels. + +Provenance from lab tools (for example LabChronicle: repository URL, commit SHA, experiment path +and per-repeat lab-tool repeat codes) is an optional, user-supplied preprocessing input recorded +**only in the run manifest**, never in `morphobricks.toml` (`labchronicle`, `provenance` and +`source_database` are `E_FORBIDDEN_FIELD`). It is excluded from every identity, pairing and cache +key. A mapping from a lab-tool code such as `R001` to a canonical repeat ID MUST be confirmed by +the user: equal strings are not evidence, because the namespaces are unrelated. + +## 10. Reconciliation and confirmation (preprocessing behaviour) + +On every run, preprocessing compares fresh discovery with the published file and classifies each +unit **unchanged**, **new**, **removed** or **conflicting**. It never rewrites confirmed semantics +silently; edits go to a separate draft and are published atomically after validation. + +- **Publish only from the current base.** Every draft records the SHA-256 of the exact bytes of + the published `morphobricks.toml` it was derived from, or the explicit value `absent` when none + existed (a draft without it: `R_DRAFT_BASE_MISSING`). Under the output folder's lock, at the + moment of publication, preprocessing MUST compute the SHA-256 of the file currently published + (or find none) and refuse with `R_PUBLISHED_FILE_CHANGED` unless it equals the recorded base. + The lock prevents concurrent writes; the base check prevents a stale draft from replacing a newer + published file. The refused draft is never merged automatically: it is re-derived from the + current file. +- **`confirmed_on` is kept** byte-identical for every repeat whose confirmation is not reopened + (§4.4a); the draft copies it from the published file, never from the clock. + +- **N35 reopening.** The confirmation of a repeat is reopened (draft `confirmed = false`) only when + channels, layout or grouping change: a new or removed unit or member, a unit's ordered channel + list, or the pairing of folders into repeats. New FOVs (or removed ones) inside an existing unit + whose confirmed layout still holds do **not** reopen it; they are reported and recorded. +- **Adding a member is allowed (AQ1).** A new member folder or unit joins an existing repeat or + condition without a changed-ID refusal (§4.6); it is a grouping change, so it reopens the N35 + confirmation of that repeat, and it never changes an existing ID. Only removing or reassigning + existing members counts as a changed entity. +- A removed member is reported and removed only on user confirmation, which is the recorded + confirmation of §4.6 (without it the candidate is `R_CONFIRMED_ID_CHANGED`); a repeat left + without members is removed from the file, and its ID stays in `used_ids` and is never reused + (`R_USED_ID_REUSED`, `R_USED_ID_DROPPED`; §4.9). +- A new folder whose label equals an existing repeat ID is proposed for that repeat but stays + pending until confirmed. +- **Condition and technical-repeat folders (fourth round).** Discovery matches a folder to an ID + by its `source_folder`, never by the ID. A folder equal to a published `source_folder` is the + same entity. A folder not in the file is new and pending, with an ID proposed under §4.6a (the + label if allowed, else another proposal); it joins no existing ID without the user's + confirmation, which for an existing ID is a reassignment (§4.6). A published `source_folder` + with no folder on disk is a removed folder, removed only on user confirmation like a removed + member. +- A conflicting unit (`R_*`) blocks publication; no per-FOV exception is created. + +## 11. Derived `experiment_design.csv` (compatibility output) + +Preprocessing MUST derive the legacy file (schema version 1, preprocessing contract §12) +deterministically from a published file, with the contract's byte rules: + +| CSV column | Value | +|---|---| +| `condition` | member's condition's `source_folder` (the folder label, not the condition ID) | +| `biological_repeat` | member's `source_repeat` | +| `repeat_group` | canonical repeat ID | +| `control` | `true` iff some condition's `reference` names this condition | +| `image_count` | member's `image_count` | +| `technical_repeat_count` | 0 if the only technical repeat is `single` (its `source_folder` is `single`), else the number of technical repeats | +| `confirmed` | `true` | +| `confirmed_on` | repeat's `confirmed_on` | +| `schema_version` | `1` | + +One row per member, sorted by `condition` (the source folder label), then `biological_repeat`, in +code point order. The CSV carries the **source folder labels exactly as today** and no technical +repeat or condition ID, so the choice of a condition or technical repeat ID (for example `ctrl` for +the folder `control`) never changes it. For a legacy dataset whose `repeat_group` values migrate +to the canonical repeat IDs and whose controls migrate deterministically, the derived file MUST be +byte-identical to the one preprocessing writes today. +Known loss: a legacy design marking a control that no other condition references (e.g. one +condition only) exports `control = false`. For a migrated group whose legacy rows carried +different `confirmed_on` dates (§7, AQ12) the derived file carries the group's one date, the +latest, on every row. **Byte-identity is guaranteed only for repeat groups with a single +`confirmed_on`**; for a group with several dates the earlier per-row dates are the only difference +and are recorded in the migration note, which is their only record. + +Expected `experiment_design.csv` outputs (golden files) are **deferred to Stage 1**; Stage 0 fixes +only the derivation rules above. + +## 12. Drafts + +A draft (reconciliation proposal, unconfirmed repeats, unresolved legacy references) is internal +to preprocessing and records its base SHA-256 (§10). It is never named `morphobricks.toml`. Downstream consumers (`RunInputs`) read +only the confirmed, published `morphobricks.toml` and MUST refuse a draft or any file with an +unconfirmed repeat (`A_DRAFT_REFUSED`; a published-path file with `confirmed = false` is also +`E_REPEAT_UNCONFIRMED`). diff --git a/tests/spec_fixtures/fixtures/README.md b/tests/spec_fixtures/fixtures/README.md new file mode 100644 index 00000000..32503b99 --- /dev/null +++ b/tests/spec_fixtures/fixtures/README.md @@ -0,0 +1,409 @@ +# Fixture catalogue: experiment description v1 + +Normative test data for [experiment-v1.md](../docs/experiment-v1.md). Each file starts with a +comment header (`Fixture`, `Expected`, `Source`, description). How these become executable tests +in preprocessing is described in the [conformance plan](../docs/conformance-plan.md); nothing here +is executed by this repository. + +## Rules + +- A conforming reader MUST accept every file in `valid/` and report exactly the warnings listed + (none unless stated). +- For every file in `invalid/` it MUST report exactly the listed **set** of error codes, all from + the first failing validation phase (experiment-v1 §6), and nothing from later phases. +- "Homogeneous: **no**" marks a valid file whose units have different ordered channel lists. The + spec accepts it; the Stage 1-2 runtime MUST refuse to process it with + `R_HETEROGENEOUS_LAYOUT_NOT_ENABLED` (lifted at Stage 3). +- Physical cases that need images (within-unit divergence, header contradictions, mask pixel + contradictions, reconciliation) cannot be expressed by a TOML file alone; they are listed as + scenarios P01-P33 in the [conformance plan](../docs/conformance-plan.md#3-physical-scenarios-preprocessing-owned), + built on the valid files below. +- `v18` MUST parse to the same model as `v01` (round trip); `v21` differs from `v01` only in + `display_name`, `target` and `treatment_key` values and `v16` only in `display_name` values, so + for both all four section digests equal `v01`'s (experiment-v1 §9.3). +- Every file carries the required `used_ids` table (experiment-v1 §4.9) listing at least all of its + declared condition, channel, repeat and technical repeat IDs; `i92` is the file without it. +- Every condition and every technical repeat carries the required `source_folder` (experiment-v1 + §4.6a): the exact folder label, never restricted by the reserved or forbidden names. In + fixtures that do not exercise the rule it equals the ID (the key), `single` for `single`; + `v33`, `v34` and `t10`-`t12` separate the two. The eight undecodable + or unparsable phase-1 fixtures (`i01`-`i08`) are unchanged and fail before it matters. +- `transitions/` holds twelve files that are valid alone and are checked against a named published + predecessor: refused with an exact set of publication codes (`R_CONFIRMED_ID_CHANGED`, + `R_USED_ID_REUSED`, `R_USED_ID_DROPPED`, `R_CONFIRMED_ON_CHANGED`; experiment-v1 §4.4a, §4.6, + §4.9; a changed or reassigned `source_folder` counts, §4.6a) or, for `t06`, **accepted** (adding members is not a changed entity, §4.6). They need the + predecessor file and are not part of the single-file valid/invalid rules. Publication-base and + folder-name rules need no TOML of their own: they run on named fixtures in scenarios P17-P22. +- Rules that need images, a run or a consumer (header matching, image type against the header, + mask checks and conversions, mask type names, the FOV key, the `target` prefill, legacy + migration dates) have no single-file TOML form: each is a scenario P23-P33 of the conformance + plan on a named fixture, listed in the coverage tables below. +- A copy of this tree vendored by a reader MUST hash to the pinned SHA's `fixtures` tree + (conformance plan §1). +- Expected `experiment_design.csv` outputs (golden files) are deferred to Stage 1. The fixed-SHA + consumer audit (roadmap S0.1) is done (PR #5); its questions are answered by the third round of + owner decisions (ADR 0001) and the fixtures `v28`-`v32`, `t06`-`t09`, `i100`-`i119` follow from + them; the fourth round (FR1, FR2) adds `v33`, `v34`, `t10`-`t12` and `i120`-`i127`. + +## Syntax check (6 November 2026) + +Checked with Python 3.13 `tomllib` (`tomllib.loads` on the UTF-8 decoded bytes), after the third +round of owner decisions and again after the fourth (folder labels, `source_folder`): + +- `valid/`: 34 of 34 parse. +- `transitions/`: 12 of 12 parse. +- `invalid/`: 119 of 127 parse as TOML and are invalid only by the v1 rules. The 8 phase-1 fixtures + fail deliberately: `i01_bom` (`TOMLDecodeError`, BOM read as an invalid statement), + `i02_invalid_utf8` (`UnicodeDecodeError`), `i03`-`i06` (`TOMLDecodeError`, "Cannot declare ... + twice" / "Cannot overwrite a value"), `i07`, `i08` (`TOMLDecodeError`, "Invalid value" / + "Expected newline"). + +## Matrix audit fixtures and executable validator (7 November 2026) + +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) +(`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 +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. + +## Reference-validator cross-check (6 November 2026, roadmap S0.1) + +A reference validator (Python 3.13; now the audit tool [`tools/audit/refval.py`](../tools/audit/refval.py), not a runtime reader) implementing experiment-v1 §2-§6 phase by phase, plus the predecessor checks of §4.6, +§4.4a and §4.9 expressible from two files, was run on every fixture after the third round of +owner decisions and run again after the fourth (folder labels, `source_folder`). The validator +implements the text as written, including the new rules (`source_folder`, one folder one ID, +`used_ids.technical_repeats`, one code per broken rule, forbidden names on ID keys, the extended +reserved list, NFC of channel references, `image_type` and the RGB rules, "changed entity" as +removal or reassignment, a changed `source_folder` included). Results: + +- `valid/`: 34 of 34 accepted with exactly the listed warnings. +- `invalid/`: 127 of 127 refused with exactly the listed code set, from the listed phase only. +- `transitions/`: 12 of 12 valid alone (`t03`, `t06`, `t07`, `t08` with + `W_REPEAT_MISSING_CONDITIONS`); against their predecessors each gives exactly its listed set: + `t01` `R_CONFIRMED_ID_CHANGED`; `t02` `R_CONFIRMED_ID_CHANGED` and `R_USED_ID_DROPPED`; `t03` + `R_USED_ID_REUSED` only; `t04` `R_USED_ID_DROPPED`; `t05` `R_CONFIRMED_ON_CHANGED`; `t06` no + code (accepted); `t07`, `t08`, `t09`, `t10`, `t11`, `t12` `R_CONFIRMED_ID_CHANGED`. +- `v18` parses to the same model as `v01`; `v16` and `v21` have the same four sections as `v01` + once `display_name`, `target` and `treatment_key` are omitted. + +This is a check of the fixtures against the text, not a conformance claim for any brick. + +## Coverage of RFC #1 and its discussion + +**RFC #1, M0 fixture list (12 cases)**, from the +[RFC body](https://github.com/CellMigrationLab/MorphoBricks-Spec/issues/1): + +| # | RFC case | Fixtures | +|---|---|---| +| 1 | One control | `v01` | +| 2 | Several controls | `v02` | +| 3 | Differently named source folders paired into one canonical repeat | `v03` | +| 4 | Swapped channel order between imaging units | `v04` | +| 5 | Variable channel counts | `v05` | +| 6 | Two technical repeats of one biological repeat with different layouts | `v06` | +| 7 | One FOV diverging inside a technical repeat (invalid) | `i20` (no override can express it); physical P01, P02 | +| 8 | Instance masks | `v07` | +| 9 | Binary masks | `v08` | +| 10 | Incomplete/progressive masks | `v09` | +| 11 | Reference chains | `v10` | +| 12 | Invalid reference cycles | `i64`, `i65` | + +**Ten adversarial tests** from the independent review comment in the +[RFC #1 discussion](https://github.com/CellMigrationLab/MorphoBricks-Spec/issues/1#issuecomment-6667477114) +(5 November 2026). Most are end-to-end obligations of later stages; the fixture fixes the input: + +| # | Adversarial test | Fixtures | Obligation | +|---|---|---|---| +| 1 | Two technical repeats of different counts/orders stay ONE biological replicate | `v06` | Stage 2-3: N per repeat in normalization, embeddings, MorphoCoverage | +| 2 | One deviating FOV in a technical repeat refused with image/C-index evidence | `i20`; P01, P02 | Stage 1: `R_UNIT_NOT_HOMOGENEOUS` | +| 3 | Swapped C order never mixes ACTIN and DNA in normalization or CellPatcher | `v04`, `v06` | Stage 1 refusal; Stage 3 pooling by canonical ID | +| 4 | Homogeneous legacy datasets keep numerical parity and existing refusals | `v13` | Stage 1: byte-identical derived CSV; Stage 3 parity | +| 5 | `DNA` with confirmed header `DAPI` passes; unconfirmed contradiction refuses | `v11`; P03 | Stage 1: `R_HEADER_CONTRADICTION` | +| 6 | A binary region mask never becomes an instance object set | `v08` | Stage 2: `A_MASK_NOT_INSTANCES` | +| 7 | N35 confirmation survives migration and reopens on discovery changes | `v13`, `i37`; P07 | Stage 1 migration and reconciliation | +| 8 | Distinct source repeat names paired by the user give one canonical ID in MorphoCoverage | `v03` | Stage 2 (S2.5) | +| 9 | Missing channels in requested scope: refusal or recorded user subset, never silent drop | `v05`, `v17` | Stage 2: `A_SCOPE_INCOMPLETE` | +| 10 | Metadata-only changes keep valid pixel-derived caches | `v01` vs `v21` | Stage 1: section digests | + +**Further required cases:** + +| Topic | Fixtures | +|---|---| +| Duplicate keys | `i03`-`i06` | +| 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` | +| Display name is not an ID | `i89`-`i91` | +| IDs never repaired, re-cased or renumbered | `v26`, `i48`-`i52`, `i55`, `i95` | +| `used_ids` registry | `v25`, `i92`-`i97`, `t03`, `t04`; P19, P20 | +| `confirmed_on` is a user fact | `i98`, `t05`; P07, P15, P22 | +| Publish only from the current base | P17, P18 (on `v01`, `v21`, `v16`) | +| Lab-tool provenance stays out of the file | `i99` | +| Unknown and forbidden fields | `i16`-`i19`; `i20`-`i26`; `i97`, `i99` | +| Implicit-conversion traps | `i07`, `i08`, `i12`-`i14`, `i19`, `i27`-`i30`, `i33`, `i34`, `i36`, `i38`, `i39`; valid side `v15` | +| Reference DAG | `i64`-`i68`, `i41`; `v10`, `v19` | +| Swapped channel order | `v04`, `v06` | +| Extra / missing channel | `v05`, `v17`, `v22`, `i69` | +| Within-unit divergence | `i20`; P01, P02 | +| Instance vs binary masks | `v07`, `v08`, `i42`, `i43`, `i111`, `i112`; P05, P27, P28 | +| Partial masks | `v09` | +| Immutable IDs (no renames) | `v16`, `i75`, `i82`, `t01`, `t02`; P08, P16 | +| Adding is not a change; removing or reassigning is | `t03`, `t06`, `t07`, `t08`; P07, P15 | +| Case-only correction needs a new ID; set of publication codes | `t02`, `t09`, `i95`; P16 | +| RGB images (declared type, fixed tokens, no aliases) | `v28`, `v29`, `i100`-`i106`, `i119`; P13, P23, P24 | +| 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` | +| 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 | +| Mask checks: when, unchecked masks, type names | `v09`; P05, P28, P29 | +| Descriptive keys prefill only | `v27`; P31 | +| Legacy migration dates (byte-identity only for a single `confirmed_on`) | `v13`; P30 | +| Derived CSV keeps the source folder labels | `v13`, `v33`; P32 | +| Aliases (header evidence) | `v11`, `v12`, `v31`, `i46`, `i81`, `i110`; P03, P26 | +| Versioning (offline constant, no default) | `i09`-`i15` | +| Identities | `v14`, `v15`, `v20`, `v23`, `v26`, `i54`-`i63`, `i78` | +| Target / treatment key (descriptive only) | `v01`, `v10`, `v21`, `v27`, `i71` | +| Encoding | `i01`, `i02` | + +### Owner decisions of 6 November 2026 (ADR 0001) and their fixtures + +| # | Decision | Fixtures / scenarios | +|---|---|---| +| D1 | Optional `target`, `treatment_key` (refined by Q4: descriptive only) | `v01`, `v10`, `v21`, `v27`; `i71` (non-string) | +| D2 | Channel ID = compartment name refused | `i61` | +| D3 | Unused channel is a warning | `v22` | +| D4 | Repeat ID = another repeat's source folder refused | `i75` | +| D5 | IDs immutable; `display_name` mutable and unique; no `retired_repeats` | `v16` (display names change, identity identical), `i53`, `i76`, `i77` (collisions), `i82` (no table), `t01`, `t02`; P16 | +| D6 | N35 reopens only on channel/layout/grouping change | P07, P15 | +| D7 | NFC identities (refined by Q3: non-NFC refused, never normalized; no NFC-collision code) | `v23`, `i78`, `i83`-`i85` | +| D8 | RGB refused (**superseded by AQ6**) | P13 (now: RGB accepted when declared) | +| D9 | Header names exact, case-sensitive | `v11`; P03 | +| D10 | `binary` mask with > 2 values refused | P05 | +| D11 | Drafts internal; `RunInputs` refuses them | `i37`; P14 | +| Q1 | Append-only `used_ids` per kind; no reuse, no drop | `v25`, `i92`-`i97`, `t03`, `t04`; P19, P20 | +| Q2 | Display names unique per kind after NFC + case folding | `v24`, `i86`-`i88` | +| Q3 | Reader refuses non-NFC identities | `v23`, `i78`, `i83`-`i85`; P21 | +| Q4 | `treatment_key`, `target` descriptive only | `v27`, `v21` | +| Q5 | Lab-tool links in the run manifest only | `i99` | +| A1 | Publish only from the current base SHA | P17, P18 | +| A2 | IDs never repaired, re-cased or renumbered | `v26`, `i95` | +| A3 | `confirmed_on` is a user fact | `i98`, `t05`; P22 | +| A4 | `display_name` never substitutes for an ID | `i89`-`i91` | +| A5 | Pin integrity, offline version check, no fallback rules | `i09`-`i11`; conformance plan §1-§2 | +| AQ1 | Adding a member is allowed; only removing or reassigning is a changed entity | `t03`, `t06`, `t07`, `t08`; P07, P15 | +| AQ2 | Case-only ID correction needs a new ID; publication reports every `R_*` code | `t02`, `t09`, `i95`; P16 | +| AQ3 | `target` descriptive; MorphoCoverage may only prefill, confirmed inside MorphoCoverage | `v27`; P31 | +| AQ4 | One reserved list: compartments plus MorphoRelate reserved column names | `i59`, `i61`, `i107`-`i109`, `v32` | +| AQ5 | Canonical repeat ID added as `repeat_group`; the FOV key keeps the source label | `v03`, `v30`; P25 | +| AQ6 | RGB accepted as a declared type (`image_type = "rgb"`, tokens `R`, `G`, `B`); supersedes D8 | `v28`, `v29`, `i100`-`i106`, `i119`; P13, P23, P24 | +| AQ7 | No trimming: header names exact after NFC of both sides | `v31`, `i110`; P26 | +| AQ8 | `parent_labels` masks out of scope in v1 | `i111` | +| AQ9 | Binary-as-instances only by an explicit, per-request, recorded conversion | `v08`, `i112`; P27 | +| AQ10 | Mask representation checked in the mask step; publication records "not yet checked"; unchecked masks refuse | `v09`; P05, P28 | +| AQ11 | A discovered mask type breaking the Label rules: `R_MASK_TYPE_NAME_INVALID` | P29 | +| AQ12 | Legacy groups with different `confirmed_on` take the latest date, with a note | `v13`; P30 | +| AQ13 | Forbidden names apply to ID keys; NFC of channel references; one code per broken rule | `i113`-`i118` | +| FR1 | Folder labels are never restricted by the reserved or forbidden names: conditions and technical repeats have a canonical ID separate from the required `source_folder`; one folder, one ID; a folder change for an existing ID is a reassignment; the derived CSV keeps the folder labels | `v33`, `v34`, `i113`, `i115`, `i120`-`i127`, `t10`-`t12`; P32, P33 | +| FR2 | Derived CSV byte-identity is guaranteed only for repeat groups with a single `confirmed_on`; a legacy group with several dates gets the latest, the migration note keeps the per-row dates | `v13`; P30 | + +## Valid fixtures (36) + +| Fixture | Source | Expected | Homogeneous | Case | +|---|---|---|---|---| +| [`v01_single_control`](valid/v01_single_control.toml) | RFC case 1 | pass | yes | One control; trivial pairing, so canonical repeat IDs default to the folder names. | +| [`v02_multiple_controls`](valid/v02_multiple_controls.toml) | RFC case 2 | pass | yes | Two controls (DMSO, WT), each referenced by two conditions. | +| [`v03_paired_different_folder_names`](valid/v03_paired_different_folder_names.toml) | RFC case 3; adversarial 8 | pass | yes | Source folders rep1 (CTRL) and experiment_23 (DrugA) paired by the user into canonical repeat Day1; rep2/rep2_drug into Day2. MorphoCoverage must see Day1 and Day2 as the biological repeats: the FOV key keeps `rep1` / `experiment_23` in `biological_repeat` and the added `repeat_group` is Day1 (P25). | +| [`v04_swapped_channel_order`](valid/v04_swapped_channel_order.toml) | RFC case 4; adversarial 3 | pass | **no** | Same channels, opposite source order between conditions (CTRL C0=DNA, DrugA C0=ACTIN). Valid schema; heterogeneous, so Stage 1-2 runtime refuses (R_HETEROGENEOUS_LAYOUT_NOT_ENABLED). From Stage 3, ACTIN and DNA are never mixed by C index. | +| [`v05_variable_channel_counts`](valid/v05_variable_channel_counts.toml) | RFC case 5 | pass | **no** | Extra channel PHASE in DrugA/rep1 only; CTRL lacks it (missing elsewhere). Heterogeneous. | +| [`v06_technical_repeats_different_layouts`](valid/v06_technical_repeats_different_layouts.toml) | RFC case 6; adversarial 1 | pass | **no** | One biological repeat (R002) whose CTRL member has two technical repeats with different counts and orders. It stays ONE biological repeat (N = 2 repeats in total, not 3). Heterogeneous. | +| [`v07_instance_masks`](valid/v07_instance_masks.toml) | RFC case 8 | pass | yes | Two instance masks with source-channel provenance; canonical mask IDs differ from discovered source types. | +| [`v08_binary_masks`](valid/v08_binary_masks.toml) | RFC case 9; adversarial 6 | pass | yes | A binary region mask beside an instance mask. Valid; an analysis request using TumorRegion as an object set must refuse (A_MASK_NOT_INSTANCES) unless it carries an explicit, per-request, recorded conversion (P27). | +| [`v09_partial_masks`](valid/v09_partial_masks.toml) | RFC case 10 | pass | yes | Masks declared while segmentation is in progress (e.g. 0/1000 or 300/1000 FOVs). Completeness lives in the run manifest, so the file is valid at every stage; scope checks belong to the analysis layer (A_SCOPE_INCOMPLETE). | +| [`v10_reference_chain`](valid/v10_reference_chain.toml) | RFC case 11 | pass | yes | Reference chains VEC_SiCol6a1 -> VEC_SiNC -> VEC and THBS5_SiCol6a1 -> THBS5_SiNC -> THBS5; shared treatment_key across backgrounds. | +| [`v11_header_aliases`](valid/v11_header_aliases.toml) | Adversarial 5 | pass | yes | Canonical DNA confirmed with header evidence DAPI and Hoechst 33342; canonical ACTIN with an exact-match header name that still had to be confirmed. A header name outside these lists is R_HEADER_CONTRADICTION. | +| [`v12_unnamed_headers`](valid/v12_unnamed_headers.toml) | Extra: aliases | pass | yes | No header evidence at all (ImageJ TIFFs carry no channel names); the user's confirmation is the only guard. Undetectable physical swaps are a documented limit. | +| [`v13_homogeneous_legacy`](valid/v13_homogeneous_legacy.toml) | Adversarial 4 | pass | yes | A legacy homogeneous dataset: folder-name repeat IDs, one control, same layout everywhere (IDs proposed equal to the folder labels). The derived experiment_design.csv, which carries the `source_folder` labels, must be byte-identical to today's; numerical outputs must keep parity. | +| [`v14_no_repeat_folders`](valid/v14_no_repeat_folders.toml) | Extra: single convention | pass | yes | No biological-repeat or technical-repeat folders: the source label is `single` (N31, one group for the whole experiment). | +| [`v15_toml_keys_are_text`](valid/v15_toml_keys_are_text.toml) | Extra: implicit-conversion traps (valid side) | pass | yes | Folder labels that YAML would retype (2026-01-01, 012, NO, 1.0, off) stay exact text in TOML keys and strings. The quoted key "1.0" is needed: a bare 1.0 would be a dotted key. | +| [`v16_display_name_change`](valid/v16_display_name_change.toml) | Owner refinement: immutable IDs | pass | yes | v01 with every `display_name` changed or added (conditions, channels, repeats). Identity is identical: all four section digests equal v01's, nothing may be invalidated. | +| [`v17_partial_channel_presence`](valid/v17_partial_channel_presence.toml) | Adversarial 9; addendum §3 | pass | **no** | PHASE exists only in CTRL/R002/acquisition_1. For a PHASE request R002 is partially represented and R001 unavailable: the analysis layer must refuse (A_SCOPE_INCOMPLETE) or record a user-selected subset, never drop or re-weight silently. | +| [`v18_inline_and_dotted_forms`](valid/v18_inline_and_dotted_forms.toml) | Extra: round trip | pass | yes | Same model as v01 written with inline tables, dotted keys and quoted keys; parse(v18) must equal parse(v01). | +| [`v19_warnings_unpaired`](valid/v19_warnings_unpaired.toml) | Extra: reference DAG semantics | pass + `W_REFERENCE_UNPAIRED`, `W_REPEAT_MISSING_CONDITIONS` | yes | Valid with warnings: CTRL only in rep1, DrugA only in rep2, so the repeats miss conditions (W_REPEAT_MISSING_CONDITIONS) and DrugA cannot be paired with its reference (W_REFERENCE_UNPAIRED). | +| [`v20_unicode_and_spaces`](valid/v20_unicode_and_spaces.toml) | Extra: identities | pass | yes | Condition folder labels with spaces and non-ASCII text are valid labels (quoted keys); channel and mask IDs stay tokens. | +| [`v21_metadata_only_edit`](valid/v21_metadata_only_edit.toml) | Adversarial 10 | pass | yes | v01 with only descriptive condition fields edited (display name, target removed, treatment_key changed). All four section digests equal v01's (descriptive fields are omitted, experiment-v1 9.3), so no pixel-derived product may be invalidated. | +| [`v22_unused_channel_warning`](valid/v22_unused_channel_warning.toml) | Owner decision 3 | pass + `W_UNUSED_CHANNEL` | yes | PHASE is declared but acquired by no unit: valid with a warning, never an error. | +| [`v23_nfc_identity`](valid/v23_nfc_identity.toml) | Owner decision Q3 | pass | yes | A condition key written composed (U+00E9, NFC) and referenced composed: the only form a writer emits and a reader accepts. | +| [`v24_display_name_as_typed`](valid/v24_display_name_as_typed.toml) | Owner decision Q2 | pass | yes | Display names kept as typed (one decomposed, one lower case); a channel and a condition share the display name "Drug A" because uniqueness is per kind. | +| [`v25_removed_condition_id_retained`](valid/v25_removed_condition_id_retained.toml) | Owner decision Q1 | pass | yes | v01 after DrugA was removed: DrugA stays listed in `used_ids.conditions`. Predecessor of t03 and t04 (and, with the recorded removal confirmation of experiment-v1 4.6, the removal of v01). | +| [`v26_ids_kept_as_written`](valid/v26_ids_kept_as_written.toml) | Audit B1, B2, B11 | pass | yes | IDs `cl001`, `CL1000`, `R999`, `R1000`: no width, prefix or case rule; kept exactly as written. | +| [`v27_descriptive_keys_collide`](valid/v27_descriptive_keys_collide.toml) | Owner decision Q4 | pass | yes | Identical and case-differing `target` / `treatment_key` values on different conditions: valid, no warning, no grouping. | +| [`v28_rgb_units`](valid/v28_rgb_units.toml) | Owner decision AQ6 (supersedes D8) | pass | yes | An RGB experiment (e.g. H&E): every unit declares `image_type = "rgb"` with the fixed channels R, G, B in that order and no aliases. Valid and homogeneous. The scientific meaning is left to each brick: a brick that does not declare RGB support refuses a request on these units (A_RGB_NOT_SUPPORTED, P24); preprocessing compares the declaration with the image header and never with pixels (P13, P23). | +| [`v29_rgb_and_channels_mixed`](valid/v29_rgb_and_channels_mixed.toml) | Owner decision AQ6 | pass | **no** | One condition with an RGB brightfield unit and a two-channel fluorescence unit of the same biological repeat. Valid schema; heterogeneous (different lists), so the Stage 1-2 runtime refuses it (R_HETEROGENEOUS_LAYOUT_NOT_ENABLED). Both units stay ONE biological repeat. | +| [`v30_same_source_label_different_repeats`](valid/v30_same_source_label_different_repeats.toml) | Owner decision AQ5; S0.1 audit section 5 | pass | yes | Source folder label rep1 is in canonical repeat Day1 for CTRL and in Day2 for DrugA (and rep2 the other way round). Valid: no repeat ID equals another repeat's source label. The FOV key keeps the source label in `biological_repeat` and stays unique through the condition; the added `repeat_group` is Day1 or Day2, and grouping and pairing use it (experiment-v1 3.4, P25). | +| [`v31_header_alias_whitespace_and_decomposed`](valid/v31_header_alias_whitespace_and_decomposed.toml) | Owner decision AQ7 | pass | yes | Header evidence is compared exactly after NFC of both sides, with no trimming. DNA lists the aliases "DAPI", " DAPI" (leading space) and "DAPI " (trailing space): outer whitespace needs its own confirmed alias, each is a distinct entry. ACTIN lists a decomposed alias (written with a TOML escape and stored as typed), which matches the header name "Caf\u00e9 488" after NFC of both sides (P26). header_names are not identities and are not NFC-checked. | +| [`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). | + +## Transition fixtures (12) + +Valid alone; checked against the named published predecessor (refused with the listed set, or accepted). + +| Fixture | Predecessor | Expected | Case | +|---|---|---|---| +| [`t01_repeat_id_changed`](transitions/t01_repeat_id_changed.toml) | `valid/v01` | `R_CONFIRMED_ID_CHANGED` unless re-confirmation is recorded | Repeats rep1/rep2 (same member folders) are now Day1/Day2; rep1, rep2 stay listed in `used_ids`. | +| [`t02_channel_id_changed`](transitions/t02_channel_id_changed.toml) | `valid/v01` | `R_CONFIRMED_ID_CHANGED`, `R_USED_ID_DROPPED` | Channel ACTIN (same header evidence and positions) is now Actin, and ACTIN is no longer listed in `used_ids`. Listing it beside `Actin` would be `E_ID_CASE_COLLISION`, so a case-only ID change cannot be published in place (owner decision AQ2): it needs a new ID, `t09`. | +| [`t03_used_id_reused`](transitions/t03_used_id_reused.toml) | `valid/v25` | `R_USED_ID_REUSED` (only) | DrugA (retired in v25) is declared again for a new source folder. rep1 gains a member, which is not a changed entity (owner decision AQ1), so `R_CONFIRMED_ID_CHANGED` does not apply. Valid alone, with `W_REPEAT_MISSING_CONDITIONS` (rep2 has no DrugA member). | +| [`t04_used_id_dropped`](transitions/t04_used_id_dropped.toml) | `valid/v25` | `R_USED_ID_DROPPED` | DrugA is no longer listed in `used_ids.conditions`. | +| [`t05_confirmed_on_changed`](transitions/t05_confirmed_on_changed.toml) | `valid/v01` | `R_CONFIRMED_ON_CHANGED` (N35 not reopened) | rep1 unchanged except `confirmed_on` 2026-11-07. | +| [`t06_member_added`](transitions/t06_member_added.toml) | `valid/v01` | no R_* code: accepted (against predecessor valid/v01) | Adding is not a change of entity. Against v01: a new condition DrugB joins the existing repeats rep1 and rep2 as a member, and a new folder CTRL/rep3 (new repeat rep3) joins the existing condition CTRL. Every member folder, position and alias of v01 is still there. The confirmation of rep1 and rep2 is reopened by the new member (grouping change, experiment-v1 10) and re-confirmed on 2026-11-07, so the changed `confirmed_on` is not R_CONFIRMED_ON_CHANGED. DrugB and rep3 are new IDs, listed in `used_ids`. Valid alone with W_REPEAT_MISSING_CONDITIONS (rep3). | +| [`t07_member_removed`](transitions/t07_member_removed.toml) | `valid/v01` | R_CONFIRMED_ID_CHANGED (against predecessor valid/v01, without a recorded removal confirmation) | Removing is a change of entity. The member DrugA/rep2 of repeat rep2 is gone (rep2 reopened and re-confirmed on 2026-11-07). The removal is accepted only with the recorded user confirmation of experiment-v1 4.6 and 10; without it the candidate is refused. All IDs stay listed in `used_ids`. Valid alone with W_REPEAT_MISSING_CONDITIONS. | +| [`t08_member_reassigned`](transitions/t08_member_reassigned.toml) | `valid/v03` | R_CONFIRMED_ID_CHANGED (against predecessor valid/v03, without a recorded confirmation) | Reassigning is a change of entity. DrugA/experiment_23 was a member of Day1 in v03 and is now the member of a new repeat Day3, so Day1 lost a member it held. Accepted only with the recorded user confirmation of experiment-v1 4.6. Day3 is a new ID listed in `used_ids`. Valid alone with W_REPEAT_MISSING_CONDITIONS. | +| [`t09_case_correction_new_id`](transitions/t09_case_correction_new_id.toml) | `valid/v01` | R_CONFIRMED_ID_CHANGED (against predecessor valid/v01, accepted only with a recorded re-confirmation) | How a case-only mistake is corrected. v01's channel ACTIN was meant to be Actin; the corrected spelling cannot be used (it collides with the retired ACTIN, see t02 and i95), so the channel gets the new, non-colliding ID ActinStain at the same positions with the same header evidence, and `display_name` "Actin" carries the appearance. ACTIN stays listed in `used_ids`. The set is exactly {R_CONFIRMED_ID_CHANGED}: no R_USED_ID_DROPPED, no R_USED_ID_REUSED, no E_* code. | +| [`t10_condition_id_changed_same_folder`](transitions/t10_condition_id_changed_same_folder.toml) | `valid/v33` | R_CONFIRMED_ID_CHANGED (against predecessor valid/v33, without a recorded re-confirmation) | The folder `control` was confirmed as condition `ctrl` in v33 and now carries the ID `control_cells`. A confirmed ID never changes in place, also when the folder is the same (experiment-v1 4.6, 4.6a). `ctrl` stays listed in used_ids, so only the changed ID is refused. Valid alone. | +| [`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) + +| Fixture | Source | Expected code(s) | Phase | Case | +|---|---|---|---|---| +| [`i01_bom`](invalid/i01_bom.toml) | Extra: encoding | `E_FILE_ENCODING` | 1 | The file starts with a UTF-8 byte-order mark. | +| [`i02_invalid_utf8`](invalid/i02_invalid_utf8.toml) | Extra: encoding | `E_FILE_ENCODING` | 1 | A label contains the byte 0xFF (not UTF-8). | +| [`i03_duplicate_condition_table`](invalid/i03_duplicate_condition_table.toml) | Extra: duplicate keys; audit §3.12 | `E_DUPLICATE_KEY` | 1 | [conditions.CTRL] declared twice (YAML would keep the second silently). | +| [`i04_duplicate_key_quoted_vs_bare`](invalid/i04_duplicate_key_quoted_vs_bare.toml) | Extra: duplicate keys | `E_DUPLICATE_KEY` | 1 | [conditions."CTRL"] is the same key as [conditions.CTRL]. | +| [`i05_duplicate_field`](invalid/i05_duplicate_field.toml) | Extra: duplicate keys | `E_DUPLICATE_KEY` | 1 | `display_name` written twice in one condition. | +| [`i06_duplicate_member_dotted_vs_table`](invalid/i06_duplicate_member_dotted_vs_table.toml) | Extra: duplicate keys | `E_DUPLICATE_KEY` | 1 | A member declared as a table and again by a dotted key (two members of one condition in one repeat). | +| [`i07_unquoted_bareword`](invalid/i07_unquoted_bareword.toml) | Extra: implicit conversion (YAML `NO` -> False) | `E_TOML_SYNTAX` | 1 | source_repeat = NO, unquoted. | +| [`i08_leading_zero_integer`](invalid/i08_leading_zero_integer.toml) | Extra: implicit conversion (YAML `012` -> 10) | `E_TOML_SYNTAX` | 1 | source_repeat = 012, unquoted. | +| [`i09_schema_version_2`](invalid/i09_schema_version_2.toml) | Extra: versioning | `E_SCHEMA_VERSION_UNSUPPORTED` | 2 | schema_version = 2 is refused, never read best effort. | +| [`i10_schema_version_0`](invalid/i10_schema_version_0.toml) | Extra: versioning | `E_SCHEMA_VERSION_UNSUPPORTED` | 2 | schema_version = 0. | +| [`i11_schema_version_missing`](invalid/i11_schema_version_missing.toml) | Extra: versioning | `E_SCHEMA_VERSION_MISSING` | 2 | No schema_version. | +| [`i12_schema_version_string`](invalid/i12_schema_version_string.toml) | Extra: implicit conversion | `E_TYPE` | 2 | schema_version = "1" (a string is not converted to an integer). | +| [`i13_schema_version_float`](invalid/i13_schema_version_float.toml) | Extra: implicit conversion | `E_TYPE` | 2 | schema_version = 1.0. | +| [`i14_schema_version_bool`](invalid/i14_schema_version_bool.toml) | Extra: implicit conversion | `E_TYPE` | 2 | schema_version = true (a boolean is not the integer 1). | +| [`i15_schema_id_wrong`](invalid/i15_schema_id_wrong.toml) | Extra: versioning | `E_SCHEMA_ID` | 2 | schema = "morphobricks-experiments". | +| [`i16_unknown_top_level_field`](invalid/i16_unknown_top_level_field.toml) | Extra: unknown fields | `E_UNKNOWN_FIELD` | 3 | Top-level `notes`. | +| [`i17_unknown_condition_field`](invalid/i17_unknown_condition_field.toml) | Extra: unknown fields | `E_UNKNOWN_FIELD` | 3 | Condition field `colour`. | +| [`i18_misspelled_field`](invalid/i18_misspelled_field.toml) | Extra: unknown fields | `E_MISSING_FIELD`, `E_UNKNOWN_FIELD` | 3 | `representaton` (typo) is not silently ignored; `representation` is then also missing. | +| [`i19_dotted_float_key`](invalid/i19_dotted_float_key.toml) | Extra: implicit conversion | `E_UNKNOWN_FIELD` | 3 | [conditions.1.0] is condition `1` with a sub-table `0`, not condition `1.0`. | +| [`i20_per_fov_override`](invalid/i20_per_fov_override.toml) | Extra: within-unit divergence; ADR (no per-FOV overrides) | `E_FORBIDDEN_FIELD` | 3 | A per-FOV channel override inside a technical repeat. | +| [`i21_mask_axes_shared`](invalid/i21_mask_axes_shared.toml) | Extra: masks; preprocessing #173 | `E_FORBIDDEN_FIELD` | 3 | Mask `axes = "YX"` and `shared = true` (v1 is 2-D only; a mask has its image's axes). | +| [`i22_brick_routing`](invalid/i22_brick_routing.toml) | Extra: ADR (no brick routing) | `E_FORBIDDEN_FIELD` | 3 | A channel routed to bricks. | +| [`i23_calibration_in_file`](invalid/i23_calibration_in_file.toml) | Extra: calibration stays in calibration.csv | `E_FORBIDDEN_FIELD` | 3 | pixel_size on a repeat. | +| [`i24_control_boolean`](invalid/i24_control_boolean.toml) | Extra: audit §3.3 | `E_FORBIDDEN_FIELD` | 3 | Legacy boolean `control` (derived from `reference` in v1). | +| [`i25_experiment_field`](invalid/i25_experiment_field.toml) | Extra: audit §3.4 | `E_FORBIDDEN_FIELD` | 3 | Condition `experiment` (an FDR family; MorphoCoverage configuration). | +| [`i26_source_index_field`](invalid/i26_source_index_field.toml) | Extra: RFC (order is the mapping) | `E_FORBIDDEN_FIELD` | 3 | A unit states `source_index` beside its ordered list. | +| [`i27_toml_date_source_repeat`](invalid/i27_toml_date_source_repeat.toml) | Extra: implicit conversion (YAML date) | `E_TYPE` | 3 | source_repeat = 2026-01-01 is a TOML date, not text. | +| [`i28_integer_source_repeat`](invalid/i28_integer_source_repeat.toml) | Extra: implicit conversion | `E_TYPE` | 3 | source_repeat = 12 (integer). | +| [`i29_bool_label`](invalid/i29_bool_label.toml) | Extra: implicit conversion (YAML `off`) | `E_TYPE` | 3 | display_name = false. | +| [`i30_confirmed_on_toml_date`](invalid/i30_confirmed_on_toml_date.toml) | Extra: implicit conversion | `E_TYPE` | 3 | confirmed_on = 2026-11-06 (TOML date literal, not a string). | +| [`i31_confirmed_on_bad_format`](invalid/i31_confirmed_on_bad_format.toml) | Extra: preprocessing contract §12 (spreadsheet dates) | `E_DATE_FORMAT` | 3 | confirmed_on = "06/11/2026". | +| [`i32_confirmed_on_impossible_date`](invalid/i32_confirmed_on_impossible_date.toml) | Extra | `E_DATE_FORMAT` | 3 | confirmed_on = "2026-02-30". | +| [`i33_image_count_string`](invalid/i33_image_count_string.toml) | Extra: implicit conversion | `E_TYPE` | 3 | image_count = "12". | +| [`i34_image_count_float`](invalid/i34_image_count_float.toml) | Extra: implicit conversion | `E_TYPE` | 3 | image_count = 12.0. | +| [`i35_image_count_zero`](invalid/i35_image_count_zero.toml) | Extra | `E_VALUE_RANGE` | 3 | image_count = 0. | +| [`i36_confirmed_string`](invalid/i36_confirmed_string.toml) | Extra: implicit conversion (legacy yes/no) | `E_TYPE` | 3 | confirmed = "yes". | +| [`i37_repeat_unconfirmed`](invalid/i37_repeat_unconfirmed.toml) | Adversarial 7 (N35) | `E_REPEAT_UNCONFIRMED` | 3 | A published file with an unconfirmed repeat. | +| [`i38_mixed_type_channel_list`](invalid/i38_mixed_type_channel_list.toml) | Extra: implicit conversion | `E_TYPE` | 3 | channels = ["DNA", 2]. | +| [`i39_channels_as_string`](invalid/i39_channels_as_string.toml) | Extra | `E_TYPE` | 3 | channels = "DNA,ACTIN" (no splitting of text). | +| [`i40_empty_channel_list`](invalid/i40_empty_channel_list.toml) | Extra | `E_EMPTY` | 3 | A unit with channels = []. | +| [`i41_reference_list`](invalid/i41_reference_list.toml) | Extra: reference DAG | `E_TYPE` | 3 | Two references in an array; v1 has one primary reference. | +| [`i42_representation_invalid`](invalid/i42_representation_invalid.toml) | Extra: masks | `E_ENUM` | 3 | representation = "labels". | +| [`i43_representation_missing`](invalid/i43_representation_missing.toml) | Extra: masks (never inferred from pixels) | `E_MISSING_FIELD` | 3 | A mask without representation. | +| [`i44_missing_channels_table`](invalid/i44_missing_channels_table.toml) | Extra | `E_MISSING_FIELD` | 3 | No [channels] at all. | +| [`i45_member_without_technical_repeats`](invalid/i45_member_without_technical_repeats.toml) | Extra | `E_MISSING_FIELD` | 3 | A member with no technical_repeats. | +| [`i46_header_name_empty`](invalid/i46_header_name_empty.toml) | Extra: aliases | `E_TEXT_EMPTY` | 3 | header_names = [""]. | +| [`i47_label_whitespace_only`](invalid/i47_label_whitespace_only.toml) | Extra | `E_TEXT_EMPTY` | 3 | display_name = " ". | +| [`i48_case_collision_conditions`](invalid/i48_case_collision_conditions.toml) | Extra: case collisions | `E_ID_CASE_COLLISION` | 4 | Conditions CTRL and ctrl. | +| [`i49_case_collision_channels`](invalid/i49_case_collision_channels.toml) | Extra: case collisions | `E_ID_CASE_COLLISION` | 4 | Channels DNA and dna. | +| [`i50_case_collision_source_repeats`](invalid/i50_case_collision_source_repeats.toml) | Extra: case collisions; preprocessing check_identity_case | `E_ID_CASE_COLLISION` | 4 | Source folders Rep1 (CTRL) and rep1 (DrugA): a label is spelled one way in the whole run. | +| [`i51_case_collision_repeat_ids`](invalid/i51_case_collision_repeat_ids.toml) | Extra: case collisions | `E_ID_CASE_COLLISION` | 4 | Canonical repeats rep1 and REP1. | +| [`i52_case_collision_technical_repeats`](invalid/i52_case_collision_technical_repeats.toml) | Extra: case collisions | `E_ID_CASE_COLLISION` | 4 | Technical repeats acq_1 and Acq_1 in different members. | +| [`i53_display_name_collision_conditions`](invalid/i53_display_name_collision_conditions.toml) | Owner refinement | `E_DISPLAY_NAME_COLLISION` | 5 | Two conditions with display_name "Control". | +| [`i54_channel_id_missing_text`](invalid/i54_channel_id_missing_text.toml) | Extra: identities (decision D-e) | `E_ID_TEXT` | 4 | Channel ID `NA` (tables read it as missing). | +| [`i55_condition_outer_whitespace`](invalid/i55_condition_outer_whitespace.toml) | Extra: identities (decision D-e) | `E_ID_TEXT` | 4 | Condition key " CTRL" with a leading space. | +| [`i56_source_repeat_null`](invalid/i56_source_repeat_null.toml) | Extra: identities | `E_ID_TEXT` | 4 | source_repeat = "null". | +| [`i57_channel_id_hyphen`](invalid/i57_channel_id_hyphen.toml) | Extra: identities (token rule) | `E_ID_SYNTAX` | 4 | Channel ID F-actin. | +| [`i58_channel_id_double_underscore`](invalid/i58_channel_id_double_underscore.toml) | Extra: identities (feature-column separator) | `E_ID_SYNTAX` | 4 | Channel ID DNA__2. | +| [`i59_mask_id_reserved`](invalid/i59_mask_id_reserved.toml) | Extra: MorphoFeature object-set rule | `E_ID_RESERVED` | 4 | Mask ID `Nucleus` (reserved compartment, any case). | +| [`i60_mask_id_reserved_in`](invalid/i60_mask_id_reserved_in.toml) | Extra: MorphoFeature object-set rule | `E_ID_RESERVED` | 4 | Mask ID `Cells_in_Tumor` (an `in` part). | +| [`i61_channel_id_reserved`](invalid/i61_channel_id_reserved.toml) | Extra: audit §3.8 | `E_ID_RESERVED` | 4 | Channel ID `Ring` (MorphoRelate compartment token). | +| [`i62_single_wrong_case`](invalid/i62_single_wrong_case.toml) | Extra: single convention | `E_SINGLE_MISUSE` | 4 | Technical repeat `Single` (spelled one way, so no case collision). | +| [`i63_single_not_alone`](invalid/i63_single_not_alone.toml) | Extra: single convention | `E_SINGLE_MISUSE` | 4 | Technical repeat `single` beside `acquisition_2`. | +| [`i64_reference_cycle`](invalid/i64_reference_cycle.toml) | RFC case 12 | `E_REFERENCE_CYCLE` | 5 | CTRL -> DrugA -> CTRL. | +| [`i65_reference_cycle_long`](invalid/i65_reference_cycle_long.toml) | RFC case 12 | `E_REFERENCE_CYCLE` | 5 | CTRL -> DrugB -> DrugA -> CTRL. | +| [`i66_reference_self`](invalid/i66_reference_self.toml) | Extra: reference DAG | `E_REFERENCE_SELF` | 5 | DrugA references itself. | +| [`i67_reference_unknown`](invalid/i67_reference_unknown.toml) | Extra: reference DAG | `E_REFERENCE_UNKNOWN` | 5 | Reference to undeclared DMSO. | +| [`i68_reference_case_mismatch`](invalid/i68_reference_case_mismatch.toml) | Extra: case rules (no folding on resolution) | `E_REFERENCE_UNKNOWN` | 5 | reference = "ctrl" does not resolve to CTRL. | +| [`i69_unknown_channel_in_unit`](invalid/i69_unknown_channel_in_unit.toml) | Extra: extra channel not declared | `E_UNKNOWN_CHANNEL` | 5 | A unit lists PHASE, which is not declared. | +| [`i70_duplicate_channel_in_unit`](invalid/i70_duplicate_channel_in_unit.toml) | Extra | `E_DUPLICATE_CHANNEL_IN_UNIT` | 5 | channels = ["DNA", "ACTIN", "DNA"]. | +| [`i71_target_not_text`](invalid/i71_target_not_text.toml) | Owner decision 1 | `E_TYPE` | 3 | target = 5 (plain strings only). | +| [`i72_member_unknown_condition`](invalid/i72_member_unknown_condition.toml) | Extra | `E_UNKNOWN_CONDITION` | 5 | A member for undeclared condition DrugB. | +| [`i73_condition_without_members`](invalid/i73_condition_without_members.toml) | Extra | `E_CONDITION_WITHOUT_MEMBERS` | 5 | Condition DrugB declared, no member. | +| [`i74_source_folder_in_two_repeats`](invalid/i74_source_folder_in_two_repeats.toml) | Extra: repeat pairing | `E_SOURCE_REPEAT_REUSED` | 5 | Source folder CTRL/exp7 belongs to repeats Day2 and Day3. | +| [`i75_repeat_id_swap`](invalid/i75_repeat_id_swap.toml) | Extra: renames | `E_REPEAT_ID_SHADOWS_SOURCE` | 5 | Repeat rep1 holds folder rep2 and repeat rep2 holds folder rep1 for CTRL. | +| [`i76_display_name_collision_repeats`](invalid/i76_display_name_collision_repeats.toml) | Owner refinement | `E_DISPLAY_NAME_COLLISION` | 5 | Repeats rep1 and rep2 both display as "Day 1". | +| [`i77_display_name_collision_channels`](invalid/i77_display_name_collision_channels.toml) | Owner refinement | `E_DISPLAY_NAME_COLLISION` | 5 | Channels DNA and ACTIN both display as "DAPI". | +| [`i78_non_nfc_condition_key`](invalid/i78_non_nfc_condition_key.toml) | Owner decision Q3 | `E_ID_NOT_NFC` | 4 | The condition key is decomposed "Cafe\u0301"; refused, never normalized. | +| [`i79_mask_source_channel_unknown`](invalid/i79_mask_source_channel_unknown.toml) | Extra: masks | `E_UNKNOWN_CHANNEL` | 5 | source_channels names undeclared Hoechst. | +| [`i80_mask_source_type_reused`](invalid/i80_mask_source_type_reused.toml) | Extra: masks | `E_MASK_SOURCE_TYPE_REUSED` | 5 | Two masks with source_type nuclei. | +| [`i81_header_names_duplicate`](invalid/i81_header_names_duplicate.toml) | Extra: aliases | `E_DUPLICATE_VALUE` | 5 | header_names = ["DAPI", "DAPI"]. | +| [`i82_retired_repeats_forbidden`](invalid/i82_retired_repeats_forbidden.toml) | Owner refinement | `E_FORBIDDEN_FIELD` | 3 | A `retired_repeats` table: IDs are immutable, v1 has no rename table. | +| [`i83_non_nfc_reference`](invalid/i83_non_nfc_reference.toml) | Owner decision Q3 | `E_ID_NOT_NFC` | 4 | A composed key referenced decomposed: refused in phase 4, not resolved through normalization. | +| [`i84_non_nfc_source_repeat`](invalid/i84_non_nfc_source_repeat.toml) | Owner decision Q3 | `E_ID_NOT_NFC` | 4 | A decomposed `source_repeat`. | +| [`i85_non_nfc_used_id`](invalid/i85_non_nfc_used_id.toml) | Owner decisions Q1, Q3 | `E_ID_NOT_NFC` | 4 | A decomposed retired ID in `used_ids`. | +| [`i86_display_name_collision_case`](invalid/i86_display_name_collision_case.toml) | Owner decision Q2 | `E_DISPLAY_NAME_COLLISION` | 5 | "Control" and "control". | +| [`i87_display_name_collision_casefold`](invalid/i87_display_name_collision_casefold.toml) | Owner decision Q2 | `E_DISPLAY_NAME_COLLISION` | 5 | Channels "Stra\u00dfe" and "STRASSE" (full case folding). | +| [`i88_display_name_collision_nfc_and_case`](invalid/i88_display_name_collision_nfc_and_case.toml) | Owner decision Q2 | `E_DISPLAY_NAME_COLLISION` | 5 | Repeats "Cafe\u0301 1" and "CAF\u00c9 1". | +| [`i89_reference_names_display_name`](invalid/i89_reference_names_display_name.toml) | Audit L1; owner decision A4 | `E_REFERENCE_UNKNOWN` | 5 | `reference` is the display name "Control" of CTRL. | +| [`i90_member_keyed_by_display_name`](invalid/i90_member_keyed_by_display_name.toml) | Audit L1 | `E_UNKNOWN_CONDITION` | 5 | A member keyed by the display name "Control". | +| [`i91_unit_lists_channel_display_name`](invalid/i91_unit_lists_channel_display_name.toml) | Audit L1 | `E_UNKNOWN_CHANNEL` | 5 | A unit lists the channel display name "DAPI". | +| [`i92_used_ids_missing`](invalid/i92_used_ids_missing.toml) | Owner decision Q1 | `E_MISSING_FIELD` | 3 | No `used_ids` table. | +| [`i93_used_id_not_listed`](invalid/i93_used_id_not_listed.toml) | Owner decision Q1 | `E_USED_ID_MISSING` | 5 | DrugA declared but not in `used_ids.conditions`. | +| [`i94_used_ids_duplicate`](invalid/i94_used_ids_duplicate.toml) | Owner decision Q1 | `E_DUPLICATE_VALUE` | 5 | rep1 listed twice. | +| [`i95_used_id_case_collision`](invalid/i95_used_id_case_collision.toml) | Audit B2, B11; owner decision Q1 | `E_ID_CASE_COLLISION` | 4 | Retired `ctrl` against declared CTRL. | +| [`i96_used_ids_mapping`](invalid/i96_used_ids_mapping.toml) | Owner decision Q1 | `E_TYPE` | 3 | `used_ids.repeats` is a mapping table, not a list. | +| [`i97_used_ids_unknown_kind`](invalid/i97_used_ids_unknown_kind.toml) | Owner decision Q1 | `E_UNKNOWN_FIELD` | 3 | `used_ids.masks`. | +| [`i98_confirmed_on_missing`](invalid/i98_confirmed_on_missing.toml) | Audit L8; owner decision A3 | `E_MISSING_FIELD` | 3 | No `confirmed_on`: no default exists. | +| [`i99_labchronicle_provenance`](invalid/i99_labchronicle_provenance.toml) | Owner decision Q5 | `E_FORBIDDEN_FIELD` | 3 | A top-level `labchronicle` table. | +| [`i100_image_type_unknown_value`](invalid/i100_image_type_unknown_value.toml) | Owner decision AQ6 | `E_ENUM` | 3 | `image_type = "RGB"` (upper case): the only value is exactly `rgb`. | +| [`i101_image_type_not_text`](invalid/i101_image_type_not_text.toml) | Owner decision AQ6 | `E_TYPE` | 3 | `image_type = true`: a boolean is not converted to text. | +| [`i102_rgb_unit_wrong_order`](invalid/i102_rgb_unit_wrong_order.toml) | Owner decision AQ6 | `E_RGB_UNIT_CHANNELS` | 5 | An RGB unit lists R, B, G: the order is fixed (R, G, B). | +| [`i103_rgb_unit_other_channels`](invalid/i103_rgb_unit_other_channels.toml) | Owner decision AQ6 | `E_RGB_UNIT_CHANNELS` | 5 | A unit declared `rgb` lists the fluorescence channels DNA and ACTIN: an RGB unit has exactly R, G, B. | +| [`i104_rgb_token_in_plain_unit`](invalid/i104_rgb_token_in_plain_unit.toml) | Owner decision AQ6 | `E_RGB_TOKEN_MISUSE` | 5 | Units that list R, G, B without `image_type = "rgb"`: the tokens are only for declared RGB units (and an undeclared RGB image is never inferred). | +| [`i105_rgb_token_lowercase_in_plain_unit`](invalid/i105_rgb_token_lowercase_in_plain_unit.toml) | Owner decision AQ6 | `E_RGB_TOKEN_MISUSE` | 5 | A fluorescence unit lists a channel `r`: R, G, B are reserved for RGB units in any case. | +| [`i106_rgb_channel_header_alias`](invalid/i106_rgb_channel_header_alias.toml) | Owner decision AQ6 | `E_RGB_ALIAS` | 5 | The channel R carries `header_names`: R, G, B have no aliases. | +| [`i107_channel_id_reserved_column`](invalid/i107_channel_id_reserved_column.toml) | Owner decision AQ4 | `E_ID_RESERVED` | 4 | Channel ID `label_id`, a MorphoRelate reserved column name (registry/_expand.py:41-59). | +| [`i108_channel_id_reserved_casefold`](invalid/i108_channel_id_reserved_casefold.toml) | Owner decision AQ4 | `E_ID_RESERVED` | 4 | Channel ID `T`: reserved names are compared case-folded, so `T` is the reserved `t`. | +| [`i109_mask_id_reserved_column`](invalid/i109_mask_id_reserved_column.toml) | Owner decision AQ4 | `E_ID_RESERVED` | 4 | Mask ID `patch_id`, a MorphoRelate reserved column name: one reserved list for channel and mask IDs. | +| [`i110_header_names_duplicate_after_nfc`](invalid/i110_header_names_duplicate_after_nfc.toml) | Owner decision AQ7 | `E_DUPLICATE_VALUE` | 5 | `header_names` = ["Caf\u00e9", "Cafe\u0301"]: header names are compared after NFC, so these are the same alias twice. | +| [`i111_mask_representation_parent_labels`](invalid/i111_mask_representation_parent_labels.toml) | Owner decision AQ8 | `E_ENUM` | 3 | `representation = "parent_labels"` (MorphoRelate's grouped masks): out of scope in v1. | +| [`i112_mask_conversion_field_forbidden`](invalid/i112_mask_conversion_field_forbidden.toml) | Owner decision AQ9 | `E_FORBIDDEN_FIELD` | 3 | `binary_mask_policy = "connected_components"` in a mask table: a binary-to-instances conversion is a per-request, recorded choice of the analysis request, never part of the file. | +| [`i113_forbidden_name_condition_key`](invalid/i113_forbidden_name_condition_key.toml) | Owner decision AQ13a | `E_FORBIDDEN_FIELD` | 3 | A condition ID `control`: forbidden names apply to ID keys as well as fields (reported at the condition and at its member keys). The folder `control` itself is describable under another ID (`v33`); only the key is refused. | +| [`i114_forbidden_name_channel_key`](invalid/i114_forbidden_name_channel_key.toml) | Owner decision AQ13a | `E_FORBIDDEN_FIELD` | 3 | A channel ID `files`: a forbidden name used as a channel key. | +| [`i115_forbidden_name_technical_repeat_key`](invalid/i115_forbidden_name_technical_repeat_key.toml) | Owner decision AQ13a | `E_FORBIDDEN_FIELD` | 3 | A technical repeat ID `overrides`: a forbidden name used as a technical-repeat key. The folder `overrides` is describable under another ID (`v34`). | +| [`i116_non_nfc_unit_channel`](invalid/i116_non_nfc_unit_channel.toml) | Owner decision AQ13b | `E_ID_NOT_NFC` | 4 | A unit lists "ACTIN\u0301" (decomposed). The reference is checked for NFC in phase 4 and is not reported as E_UNKNOWN_CHANNEL (phase 5). | +| [`i117_non_nfc_source_channel`](invalid/i117_non_nfc_source_channel.toml) | Owner decision AQ13b | `E_ID_NOT_NFC` | 4 | A mask's `source_channels` lists "DNA\u0301" (decomposed): NFC before resolution, so not E_UNKNOWN_CHANNEL. | +| [`i118_channel_id_whitespace_and_syntax`](invalid/i118_channel_id_whitespace_and_syntax.toml) | Owner decision AQ13c | `E_ID_TEXT`, `E_ID_SYNTAX` | 4 | Channel ID " DNA" (leading space): it breaks two rules, the Label rule (outer whitespace) and the Token expression, and gets both codes (one code per broken rule). | +| [`i119_rgb_unit_two_channels`](invalid/i119_rgb_unit_two_channels.toml) | Owner decision AQ6 | `E_RGB_UNIT_CHANNELS` | 5 | An RGB unit lists only R and G: exactly three channels are required. | +| [`i120_condition_missing_source_folder`](invalid/i120_condition_missing_source_folder.toml) | Owner decision FR1 | `E_MISSING_FIELD` | 3 | Condition DrugA has no `source_folder`: it is required, with no default (experiment-v1 4.2). | +| [`i121_technical_repeat_missing_source_folder`](invalid/i121_technical_repeat_missing_source_folder.toml) | Owner decision FR1 | `E_MISSING_FIELD` | 3 | The unit rep2/DrugA/single has no `source_folder`: required for every technical repeat, `single` included (experiment-v1 4.4). | +| [`i122_two_condition_ids_one_folder`](invalid/i122_two_condition_ids_one_folder.toml) | Owner decision FR1 | `E_SOURCE_FOLDER_REUSED` | 5 | Condition IDs CTRL and DrugA both name the folder `control`: one folder, one ID (experiment-v1 4.6a). | +| [`i123_two_technical_repeat_ids_one_folder`](invalid/i123_two_technical_repeat_ids_one_folder.toml) | Owner decision FR1 | `E_SOURCE_FOLDER_REUSED` | 5 | Technical repeat IDs acq1 and acq2 of one member both name the folder `acquisition_1` (experiment-v1 4.6a). | +| [`i124_one_technical_repeat_id_two_folders`](invalid/i124_one_technical_repeat_id_two_folders.toml) | Owner decision FR1 | `E_SOURCE_FOLDER_CONFLICT` | 5 | The technical repeat ID acq1 is declared with the folder `acquisition_1` in CTRL and `acq_1` in DrugA: one ID, one folder over the whole file (experiment-v1 4.6a). | +| [`i125_single_id_with_real_folder`](invalid/i125_single_id_with_real_folder.toml) | Owner decision FR1 | `E_SINGLE_MISUSE` | 4 | The technical repeat ID `single` names the folder `acquisition_1`: `single` means no technical-repeat folder level and is the ID of `source_folder = "single"` only (experiment-v1 3.2). | +| [`i126_used_ids_technical_repeats_missing`](invalid/i126_used_ids_technical_repeats_missing.toml) | Owner decision FR1 | `E_MISSING_FIELD` | 3 | `used_ids` has no `technical_repeats` list: required, like the other three kinds (experiment-v1 4.9). | +| [`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`. | diff --git a/tests/spec_fixtures/fixtures/invalid/i01_bom.toml b/tests/spec_fixtures/fixtures/invalid/i01_bom.toml new file mode 100644 index 00000000..c1c036f9 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i01_bom.toml @@ -0,0 +1,43 @@ +# Fixture i01_bom +# Expected: E_FILE_ENCODING +# Source: Extra: encoding +# The file starts with a UTF-8 byte-order mark. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +display_name = "Control" + +[conditions.DrugA] +display_name = "Drug A" +reference = "CTRL" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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] +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] diff --git a/tests/spec_fixtures/fixtures/invalid/i02_invalid_utf8.toml b/tests/spec_fixtures/fixtures/invalid/i02_invalid_utf8.toml new file mode 100644 index 00000000..24fa1d12 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i02_invalid_utf8.toml @@ -0,0 +1,43 @@ +# Fixture i02_invalid_utf8 +# Expected: E_FILE_ENCODING +# Source: Extra: encoding +# A label contains the byte 0xFF (not UTF-8). +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +display_name = "Contr�l" + +[conditions.DrugA] +display_name = "Drug A" +reference = "CTRL" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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] +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] diff --git a/tests/spec_fixtures/fixtures/invalid/i03_duplicate_condition_table.toml b/tests/spec_fixtures/fixtures/invalid/i03_duplicate_condition_table.toml new file mode 100644 index 00000000..a6d8635f --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i03_duplicate_condition_table.toml @@ -0,0 +1,46 @@ +# Fixture i03_duplicate_condition_table +# Expected: E_DUPLICATE_KEY +# Source: Extra: duplicate keys; audit §3.12 +# [conditions.CTRL] declared twice (YAML would keep the second silently). +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +display_name = "Control" + +[conditions.DrugA] +display_name = "Drug A" +reference = "CTRL" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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] +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[conditions.CTRL] +display_name = "Control again" diff --git a/tests/spec_fixtures/fixtures/invalid/i04_duplicate_key_quoted_vs_bare.toml b/tests/spec_fixtures/fixtures/invalid/i04_duplicate_key_quoted_vs_bare.toml new file mode 100644 index 00000000..a5e3dbdc --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i04_duplicate_key_quoted_vs_bare.toml @@ -0,0 +1,46 @@ +# Fixture i04_duplicate_key_quoted_vs_bare +# Expected: E_DUPLICATE_KEY +# Source: Extra: duplicate keys +# [conditions."CTRL"] is the same key as [conditions.CTRL]. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +display_name = "Control" + +[conditions.DrugA] +display_name = "Drug A" +reference = "CTRL" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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] +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[conditions."CTRL"] +target = "X" diff --git a/tests/spec_fixtures/fixtures/invalid/i05_duplicate_field.toml b/tests/spec_fixtures/fixtures/invalid/i05_duplicate_field.toml new file mode 100644 index 00000000..8ea22940 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i05_duplicate_field.toml @@ -0,0 +1,44 @@ +# Fixture i05_duplicate_field +# Expected: E_DUPLICATE_KEY +# Source: Extra: duplicate keys +# `display_name` written twice in one condition. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +display_name = "Control" +display_name = "Vehicle" + +[conditions.DrugA] +display_name = "Drug A" +reference = "CTRL" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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] +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] diff --git a/tests/spec_fixtures/fixtures/invalid/i06_duplicate_member_dotted_vs_table.toml b/tests/spec_fixtures/fixtures/invalid/i06_duplicate_member_dotted_vs_table.toml new file mode 100644 index 00000000..3d09741c --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i06_duplicate_member_dotted_vs_table.toml @@ -0,0 +1,44 @@ +# Fixture i06_duplicate_member_dotted_vs_table +# Expected: E_DUPLICATE_KEY +# Source: Extra: duplicate keys +# A member declared as a table and again by a dotted key (two members of one condition in one repeat). +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +display_name = "Control" + +[conditions.DrugA] +display_name = "Drug A" +reference = "CTRL" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[repeats.rep1] +members.CTRL.source_repeat = "rep1b" +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep1.members.CTRL] +source_repeat = "rep1" +image_count = 12 + +[repeats.rep1.members.CTRL.technical_repeats.single] +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] diff --git a/tests/spec_fixtures/fixtures/invalid/i07_unquoted_bareword.toml b/tests/spec_fixtures/fixtures/invalid/i07_unquoted_bareword.toml new file mode 100644 index 00000000..ff79088c --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i07_unquoted_bareword.toml @@ -0,0 +1,43 @@ +# Fixture i07_unquoted_bareword +# Expected: E_TOML_SYNTAX +# Source: Extra: implicit conversion (YAML `NO` -> False) +# source_repeat = NO, unquoted. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +display_name = "Control" + +[conditions.DrugA] +display_name = "Drug A" +reference = "CTRL" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep1.members.CTRL] +source_repeat = NO +image_count = 12 + +[repeats.rep1.members.CTRL.technical_repeats.single] +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] diff --git a/tests/spec_fixtures/fixtures/invalid/i08_leading_zero_integer.toml b/tests/spec_fixtures/fixtures/invalid/i08_leading_zero_integer.toml new file mode 100644 index 00000000..d72b78f6 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i08_leading_zero_integer.toml @@ -0,0 +1,43 @@ +# Fixture i08_leading_zero_integer +# Expected: E_TOML_SYNTAX +# Source: Extra: implicit conversion (YAML `012` -> 10) +# source_repeat = 012, unquoted. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +display_name = "Control" + +[conditions.DrugA] +display_name = "Drug A" +reference = "CTRL" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep1.members.CTRL] +source_repeat = 012 +image_count = 12 + +[repeats.rep1.members.CTRL.technical_repeats.single] +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] diff --git a/tests/spec_fixtures/fixtures/invalid/i09_schema_version_2.toml b/tests/spec_fixtures/fixtures/invalid/i09_schema_version_2.toml new file mode 100644 index 00000000..562e9ad3 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i09_schema_version_2.toml @@ -0,0 +1,53 @@ +# Fixture i09_schema_version_2 +# Expected: E_SCHEMA_VERSION_UNSUPPORTED +# Source: Extra: versioning +# schema_version = 2 is refused, never read best effort. +schema = "morphobricks-experiment" +schema_version = 2 + +[conditions.CTRL] +source_folder = "CTRL" +display_name = "Control" + +[conditions.DrugA] +source_folder = "DrugA" +display_name = "Drug A" +reference = "CTRL" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i100_image_type_unknown_value.toml b/tests/spec_fixtures/fixtures/invalid/i100_image_type_unknown_value.toml new file mode 100644 index 00000000..16d0460b --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i100_image_type_unknown_value.toml @@ -0,0 +1,54 @@ +# Fixture i100_image_type_unknown_value +# Expected: E_ENUM +# Source: Owner decision AQ6 +# `image_type = "RGB"` (upper case): the only value is exactly `rgb`. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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"] +image_type = "RGB" + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i101_image_type_not_text.toml b/tests/spec_fixtures/fixtures/invalid/i101_image_type_not_text.toml new file mode 100644 index 00000000..fd220afd --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i101_image_type_not_text.toml @@ -0,0 +1,54 @@ +# Fixture i101_image_type_not_text +# Expected: E_TYPE +# Source: Owner decision AQ6 +# `image_type = true`: a boolean is not converted to text. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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"] +image_type = true + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i102_rgb_unit_wrong_order.toml b/tests/spec_fixtures/fixtures/invalid/i102_rgb_unit_wrong_order.toml new file mode 100644 index 00000000..ef4cd46d --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i102_rgb_unit_wrong_order.toml @@ -0,0 +1,78 @@ +# Fixture i102_rgb_unit_wrong_order +# Expected: E_RGB_UNIT_CHANNELS +# Source: Owner decision AQ6 +# An RGB unit lists R, B, G: the order is fixed (R, G, B). +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" + +[channels.R] +display_name = "Red" + +[channels.G] +display_name = "Green" + +[channels.B] +display_name = "Blue" + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep1.members.CTRL] +source_repeat = "rep1" +image_count = 8 + +[repeats.rep1.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["R", "B", "G"] +image_type = "rgb" + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 8 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["R", "G", "B"] +image_type = "rgb" + +[repeats.rep2] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep2.members.CTRL] +source_repeat = "rep2" +image_count = 8 + +[repeats.rep2.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["R", "G", "B"] +image_type = "rgb" + +[repeats.rep2.members.DrugA] +source_repeat = "rep2" +image_count = 8 + +[repeats.rep2.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["R", "G", "B"] +image_type = "rgb" + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["B", "G", "R"] +repeats = ["rep1", "rep2"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i103_rgb_unit_other_channels.toml b/tests/spec_fixtures/fixtures/invalid/i103_rgb_unit_other_channels.toml new file mode 100644 index 00000000..5ef1b2e7 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i103_rgb_unit_other_channels.toml @@ -0,0 +1,54 @@ +# Fixture i103_rgb_unit_other_channels +# Expected: E_RGB_UNIT_CHANNELS +# Source: Owner decision AQ6 +# A unit declared `rgb` lists the fluorescence channels DNA and ACTIN: an RGB unit has exactly R, G, B. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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"] +image_type = "rgb" + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i104_rgb_token_in_plain_unit.toml b/tests/spec_fixtures/fixtures/invalid/i104_rgb_token_in_plain_unit.toml new file mode 100644 index 00000000..52dfb6b6 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i104_rgb_token_in_plain_unit.toml @@ -0,0 +1,74 @@ +# Fixture i104_rgb_token_in_plain_unit +# Expected: E_RGB_TOKEN_MISUSE +# Source: Owner decision AQ6 +# Units that list R, G, B without `image_type = "rgb"`: the tokens are only for declared RGB units (and an undeclared RGB image is never inferred). +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" + +[channels.R] +display_name = "Red" + +[channels.G] +display_name = "Green" + +[channels.B] +display_name = "Blue" + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep1.members.CTRL] +source_repeat = "rep1" +image_count = 8 + +[repeats.rep1.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["R", "G", "B"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 8 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["R", "G", "B"] + +[repeats.rep2] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep2.members.CTRL] +source_repeat = "rep2" +image_count = 8 + +[repeats.rep2.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["R", "G", "B"] + +[repeats.rep2.members.DrugA] +source_repeat = "rep2" +image_count = 8 + +[repeats.rep2.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["R", "G", "B"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["B", "G", "R"] +repeats = ["rep1", "rep2"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i105_rgb_token_lowercase_in_plain_unit.toml b/tests/spec_fixtures/fixtures/invalid/i105_rgb_token_lowercase_in_plain_unit.toml new file mode 100644 index 00000000..45815dd4 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i105_rgb_token_lowercase_in_plain_unit.toml @@ -0,0 +1,53 @@ +# Fixture i105_rgb_token_lowercase_in_plain_unit +# Expected: E_RGB_TOKEN_MISUSE +# Source: Owner decision AQ6 +# A fluorescence unit lists a channel `r`: R, G, B are reserved for RGB units in any case. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.r] +display_name = "Red fluorophore" + +[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", "r"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "r"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["DNA", "r"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i106_rgb_channel_header_alias.toml b/tests/spec_fixtures/fixtures/invalid/i106_rgb_channel_header_alias.toml new file mode 100644 index 00000000..f22098be --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i106_rgb_channel_header_alias.toml @@ -0,0 +1,79 @@ +# Fixture i106_rgb_channel_header_alias +# Expected: E_RGB_ALIAS +# Source: Owner decision AQ6 +# The channel R carries `header_names`: R, G, B have no aliases. +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" + +[channels.R] +display_name = "Red" +header_names = ["Red"] + +[channels.G] +display_name = "Green" + +[channels.B] +display_name = "Blue" + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep1.members.CTRL] +source_repeat = "rep1" +image_count = 8 + +[repeats.rep1.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["R", "G", "B"] +image_type = "rgb" + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 8 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["R", "G", "B"] +image_type = "rgb" + +[repeats.rep2] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep2.members.CTRL] +source_repeat = "rep2" +image_count = 8 + +[repeats.rep2.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["R", "G", "B"] +image_type = "rgb" + +[repeats.rep2.members.DrugA] +source_repeat = "rep2" +image_count = 8 + +[repeats.rep2.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["R", "G", "B"] +image_type = "rgb" + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["B", "G", "R"] +repeats = ["rep1", "rep2"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i107_channel_id_reserved_column.toml b/tests/spec_fixtures/fixtures/invalid/i107_channel_id_reserved_column.toml new file mode 100644 index 00000000..33fc21de --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i107_channel_id_reserved_column.toml @@ -0,0 +1,53 @@ +# Fixture i107_channel_id_reserved_column +# Expected: E_ID_RESERVED +# Source: Owner decision AQ4 +# Channel ID `label_id`, a MorphoRelate reserved column name (registry/_expand.py:41-59). +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.label_id] +display_name = "F-actin" + +[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", "label_id"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "label_id"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["DNA", "label_id"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i108_channel_id_reserved_casefold.toml b/tests/spec_fixtures/fixtures/invalid/i108_channel_id_reserved_casefold.toml new file mode 100644 index 00000000..5de20975 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i108_channel_id_reserved_casefold.toml @@ -0,0 +1,53 @@ +# Fixture i108_channel_id_reserved_casefold +# Expected: E_ID_RESERVED +# Source: Owner decision AQ4 +# Channel ID `T`: reserved names are compared case-folded, so `T` is the reserved `t`. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.T] +display_name = "F-actin" + +[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", "T"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "T"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["DNA", "T"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i109_mask_id_reserved_column.toml b/tests/spec_fixtures/fixtures/invalid/i109_mask_id_reserved_column.toml new file mode 100644 index 00000000..410e4197 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i109_mask_id_reserved_column.toml @@ -0,0 +1,53 @@ +# Fixture i109_mask_id_reserved_column +# Expected: E_ID_RESERVED +# Source: Owner decision AQ4 +# Mask ID `patch_id`, a MorphoRelate reserved column name: one reserved list for channel and mask IDs. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.patch_id] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i10_schema_version_0.toml b/tests/spec_fixtures/fixtures/invalid/i10_schema_version_0.toml new file mode 100644 index 00000000..12afc2a8 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i10_schema_version_0.toml @@ -0,0 +1,53 @@ +# Fixture i10_schema_version_0 +# Expected: E_SCHEMA_VERSION_UNSUPPORTED +# Source: Extra: versioning +# schema_version = 0. +schema = "morphobricks-experiment" +schema_version = 0 + +[conditions.CTRL] +source_folder = "CTRL" +display_name = "Control" + +[conditions.DrugA] +source_folder = "DrugA" +display_name = "Drug A" +reference = "CTRL" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i110_header_names_duplicate_after_nfc.toml b/tests/spec_fixtures/fixtures/invalid/i110_header_names_duplicate_after_nfc.toml new file mode 100644 index 00000000..fbc2f61f --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i110_header_names_duplicate_after_nfc.toml @@ -0,0 +1,53 @@ +# Fixture i110_header_names_duplicate_after_nfc +# Expected: E_DUPLICATE_VALUE +# Source: Owner decision AQ7 +# `header_names` = ["Caf\u00e9", "Cafe\u0301"]: header names are compared after NFC, so these are the same alias twice. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["Caf\u00e9", "Cafe\u0301"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i111_mask_representation_parent_labels.toml b/tests/spec_fixtures/fixtures/invalid/i111_mask_representation_parent_labels.toml new file mode 100644 index 00000000..9dab554d --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i111_mask_representation_parent_labels.toml @@ -0,0 +1,53 @@ +# Fixture i111_mask_representation_parent_labels +# Expected: E_ENUM +# Source: Owner decision AQ8 +# `representation = "parent_labels"` (MorphoRelate's grouped masks): out of scope in v1. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "parent_labels" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i112_mask_conversion_field_forbidden.toml b/tests/spec_fixtures/fixtures/invalid/i112_mask_conversion_field_forbidden.toml new file mode 100644 index 00000000..14eaf89c --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i112_mask_conversion_field_forbidden.toml @@ -0,0 +1,54 @@ +# Fixture i112_mask_conversion_field_forbidden +# Expected: E_FORBIDDEN_FIELD +# Source: Owner decision AQ9 +# `binary_mask_policy = "connected_components"` in a mask table: a binary-to-instances conversion is a per-request, recorded choice of the analysis request, never part of the file. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "binary" +binary_mask_policy = "connected_components" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i113_forbidden_name_condition_key.toml b/tests/spec_fixtures/fixtures/invalid/i113_forbidden_name_condition_key.toml new file mode 100644 index 00000000..8cebf213 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i113_forbidden_name_condition_key.toml @@ -0,0 +1,53 @@ +# Fixture i113_forbidden_name_condition_key +# Expected: E_FORBIDDEN_FIELD +# Source: Owner decision AQ13a +# A condition ID `control`: forbidden names apply to ID keys as well as fields (reported at the condition and at its member keys). The folder `control` itself is describable under another ID (v33); only the key is refused. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.control] +source_folder = "control" +display_name = "Control" + +[conditions.DrugA] +source_folder = "DrugA" +display_name = "Drug A" +reference = "control" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep1.members.control] +source_repeat = "rep1" +image_count = 12 + +[repeats.rep1.members.control.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["control", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i114_forbidden_name_channel_key.toml b/tests/spec_fixtures/fixtures/invalid/i114_forbidden_name_channel_key.toml new file mode 100644 index 00000000..22dd2592 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i114_forbidden_name_channel_key.toml @@ -0,0 +1,53 @@ +# Fixture i114_forbidden_name_channel_key +# Expected: E_FORBIDDEN_FIELD +# Source: Owner decision AQ13a +# A channel ID `files`: a forbidden name used as a channel key. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.files] +display_name = "F-actin" + +[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", "files"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "files"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["DNA", "files"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i115_forbidden_name_technical_repeat_key.toml b/tests/spec_fixtures/fixtures/invalid/i115_forbidden_name_technical_repeat_key.toml new file mode 100644 index 00000000..c3b66b41 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i115_forbidden_name_technical_repeat_key.toml @@ -0,0 +1,53 @@ +# Fixture i115_forbidden_name_technical_repeat_key +# Expected: E_FORBIDDEN_FIELD +# Source: Owner decision AQ13a +# A technical repeat ID `overrides`: a forbidden name used as a technical-repeat key. The folder `overrides` is describable under another ID (v34). +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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.overrides] +source_folder = "overrides" +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["overrides", "single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i116_non_nfc_unit_channel.toml b/tests/spec_fixtures/fixtures/invalid/i116_non_nfc_unit_channel.toml new file mode 100644 index 00000000..c50e93b8 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i116_non_nfc_unit_channel.toml @@ -0,0 +1,53 @@ +# Fixture i116_non_nfc_unit_channel +# Expected: E_ID_NOT_NFC +# Source: Owner decision AQ13b +# A unit lists "ACTIN\u0301" (decomposed). The reference is checked for NFC in phase 4 and is not reported as E_UNKNOWN_CHANNEL (phase 5). +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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\u0301"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i117_non_nfc_source_channel.toml b/tests/spec_fixtures/fixtures/invalid/i117_non_nfc_source_channel.toml new file mode 100644 index 00000000..720eb3c6 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i117_non_nfc_source_channel.toml @@ -0,0 +1,53 @@ +# Fixture i117_non_nfc_source_channel +# Expected: E_ID_NOT_NFC +# Source: Owner decision AQ13b +# A mask's `source_channels` lists "DNA\u0301" (decomposed): NFC before resolution, so not E_UNKNOWN_CHANNEL. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA\u0301"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i118_channel_id_whitespace_and_syntax.toml b/tests/spec_fixtures/fixtures/invalid/i118_channel_id_whitespace_and_syntax.toml new file mode 100644 index 00000000..e1b76612 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i118_channel_id_whitespace_and_syntax.toml @@ -0,0 +1,53 @@ +# Fixture i118_channel_id_whitespace_and_syntax +# Expected: E_ID_TEXT, E_ID_SYNTAX +# Source: Owner decision AQ13c +# Channel ID " DNA" (leading space): it breaks two rules, the Label rule (outer whitespace) and the Token expression, and gets both codes (one code per broken rule). +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" + +[channels." DNA"] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = [" DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = [" DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = [" DNA", "ACTIN"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i119_rgb_unit_two_channels.toml b/tests/spec_fixtures/fixtures/invalid/i119_rgb_unit_two_channels.toml new file mode 100644 index 00000000..b3b695f1 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i119_rgb_unit_two_channels.toml @@ -0,0 +1,78 @@ +# Fixture i119_rgb_unit_two_channels +# Expected: E_RGB_UNIT_CHANNELS +# Source: Owner decision AQ6 +# An RGB unit lists only R and G: exactly three channels are required. +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" + +[channels.R] +display_name = "Red" + +[channels.G] +display_name = "Green" + +[channels.B] +display_name = "Blue" + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep1.members.CTRL] +source_repeat = "rep1" +image_count = 8 + +[repeats.rep1.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["R", "G"] +image_type = "rgb" + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 8 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["R", "G", "B"] +image_type = "rgb" + +[repeats.rep2] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep2.members.CTRL] +source_repeat = "rep2" +image_count = 8 + +[repeats.rep2.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["R", "G", "B"] +image_type = "rgb" + +[repeats.rep2.members.DrugA] +source_repeat = "rep2" +image_count = 8 + +[repeats.rep2.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["R", "G", "B"] +image_type = "rgb" + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["B", "G", "R"] +repeats = ["rep1", "rep2"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i11_schema_version_missing.toml b/tests/spec_fixtures/fixtures/invalid/i11_schema_version_missing.toml new file mode 100644 index 00000000..ea2fbca6 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i11_schema_version_missing.toml @@ -0,0 +1,52 @@ +# Fixture i11_schema_version_missing +# Expected: E_SCHEMA_VERSION_MISSING +# Source: Extra: versioning +# No schema_version. +schema = "morphobricks-experiment" + +[conditions.CTRL] +source_folder = "CTRL" +display_name = "Control" + +[conditions.DrugA] +source_folder = "DrugA" +display_name = "Drug A" +reference = "CTRL" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i120_condition_missing_source_folder.toml b/tests/spec_fixtures/fixtures/invalid/i120_condition_missing_source_folder.toml new file mode 100644 index 00000000..1ac91a79 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i120_condition_missing_source_folder.toml @@ -0,0 +1,70 @@ +# Fixture i120_condition_missing_source_folder +# Expected: E_MISSING_FIELD +# Source: Owner decision of 6 Nov 2026 (fourth round) +# Condition DrugA has no `source_folder`: it is required, with no default (experiment-v1 4.2). +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" +display_name = "Control" + +[conditions.DrugA] +display_name = "Drug A" +reference = "CTRL" +target = "SRC" +treatment_key = "saracatinib" + +[channels.DNA] +display_name = "DAPI" +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/invalid/i121_technical_repeat_missing_source_folder.toml b/tests/spec_fixtures/fixtures/invalid/i121_technical_repeat_missing_source_folder.toml new file mode 100644 index 00000000..42a9ee69 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i121_technical_repeat_missing_source_folder.toml @@ -0,0 +1,70 @@ +# Fixture i121_technical_repeat_missing_source_folder +# Expected: E_MISSING_FIELD +# Source: Owner decision of 6 Nov 2026 (fourth round) +# The unit rep2/DrugA/single has no `source_folder`: required for every technical repeat, `single` included (experiment-v1 4.4). +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" +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] +channels = ["DNA", "ACTIN"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1", "rep2"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i122_two_condition_ids_one_folder.toml b/tests/spec_fixtures/fixtures/invalid/i122_two_condition_ids_one_folder.toml new file mode 100644 index 00000000..b46dd28d --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i122_two_condition_ids_one_folder.toml @@ -0,0 +1,71 @@ +# Fixture i122_two_condition_ids_one_folder +# Expected: E_SOURCE_FOLDER_REUSED +# Source: Owner decision of 6 Nov 2026 (fourth round) +# Condition IDs CTRL and DrugA both name the folder `control`: one folder, one ID (experiment-v1 4.6a). +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "control" +display_name = "Control" + +[conditions.DrugA] +source_folder = "control" +display_name = "Drug A" +reference = "CTRL" +target = "SRC" +treatment_key = "saracatinib" + +[channels.DNA] +display_name = "DAPI" +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/invalid/i123_two_technical_repeat_ids_one_folder.toml b/tests/spec_fixtures/fixtures/invalid/i123_two_technical_repeat_ids_one_folder.toml new file mode 100644 index 00000000..262821df --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i123_two_technical_repeat_ids_one_folder.toml @@ -0,0 +1,75 @@ +# Fixture i123_two_technical_repeat_ids_one_folder +# Expected: E_SOURCE_FOLDER_REUSED +# Source: Owner decision of 6 Nov 2026 (fourth round) +# Technical repeat IDs acq1 and acq2 of one member both name the folder `acquisition_1` (experiment-v1 4.6a). +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" +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.acq1] +source_folder = "acquisition_1" +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.CTRL.technical_repeats.acq2] +source_folder = "acquisition_1" +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 = ["acq1", "acq2", "single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i124_one_technical_repeat_id_two_folders.toml b/tests/spec_fixtures/fixtures/invalid/i124_one_technical_repeat_id_two_folders.toml new file mode 100644 index 00000000..aec57a29 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i124_one_technical_repeat_id_two_folders.toml @@ -0,0 +1,71 @@ +# Fixture i124_one_technical_repeat_id_two_folders +# Expected: E_SOURCE_FOLDER_CONFLICT +# Source: Owner decision of 6 Nov 2026 (fourth round) +# The technical repeat ID acq1 is declared with the folder `acquisition_1` in CTRL and `acq_1` in DrugA: one ID, one folder over the whole file (experiment-v1 4.6a). +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" +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.acq1] +source_folder = "acquisition_1" +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 11 + +[repeats.rep1.members.DrugA.technical_repeats.acq1] +source_folder = "acq_1" +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 = ["acq1", "single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i125_single_id_with_real_folder.toml b/tests/spec_fixtures/fixtures/invalid/i125_single_id_with_real_folder.toml new file mode 100644 index 00000000..95bea645 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i125_single_id_with_real_folder.toml @@ -0,0 +1,71 @@ +# Fixture i125_single_id_with_real_folder +# Expected: E_SINGLE_MISUSE +# Source: Owner decision of 6 Nov 2026 (fourth round) +# The technical repeat ID `single` names the folder `acquisition_1`: `single` means no technical-repeat folder level and is the ID of `source_folder = "single"` only (experiment-v1 3.2). +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" +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 = "acquisition_1" +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/invalid/i126_used_ids_technical_repeats_missing.toml b/tests/spec_fixtures/fixtures/invalid/i126_used_ids_technical_repeats_missing.toml new file mode 100644 index 00000000..767cfb28 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i126_used_ids_technical_repeats_missing.toml @@ -0,0 +1,70 @@ +# Fixture i126_used_ids_technical_repeats_missing +# Expected: E_MISSING_FIELD +# Source: Owner decision of 6 Nov 2026 (fourth round) +# `used_ids` has no `technical_repeats` list: required, like the other three kinds (experiment-v1 4.9). +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" +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"] diff --git a/tests/spec_fixtures/fixtures/invalid/i127_technical_repeat_id_not_in_used_ids.toml b/tests/spec_fixtures/fixtures/invalid/i127_technical_repeat_id_not_in_used_ids.toml new file mode 100644 index 00000000..51470d44 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i127_technical_repeat_id_not_in_used_ids.toml @@ -0,0 +1,71 @@ +# Fixture i127_technical_repeat_id_not_in_used_ids +# Expected: E_USED_ID_MISSING +# Source: Owner decision of 6 Nov 2026 (fourth round) +# The declared technical repeat ID `single` is not listed in `used_ids.technical_repeats` (experiment-v1 4.9). +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" +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.acq1] +source_folder = "acquisition_1" +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 = ["acq1"] diff --git a/tests/spec_fixtures/fixtures/invalid/i128_condition_folder_single_wrong_case.toml b/tests/spec_fixtures/fixtures/invalid/i128_condition_folder_single_wrong_case.toml new file mode 100644 index 00000000..e35b5cd6 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i128_condition_folder_single_wrong_case.toml @@ -0,0 +1,46 @@ +# Fixture i128_condition_folder_single_wrong_case +# Expected: E_SINGLE_MISUSE +# Source: Matrix audit 2026-11-07 (case F18) +# A condition `source_folder` `Single`: a source folder whose case-folded form is `single` must be exactly `single` (3.2); preprocessing refuses the folder at discovery (`reserved_folder_name`). +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "Single" + +[conditions.DrugA] +source_folder = "DrugA" +reference = "CTRL" + +[channels.ACTIN] +header_names = ["Actin"] + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep1.members.CTRL] +source_repeat = "rep1" +image_count = 2 + +[repeats.rep1.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 2 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i129_source_repeat_single_not_alone.toml b/tests/spec_fixtures/fixtures/invalid/i129_source_repeat_single_not_alone.toml new file mode 100644 index 00000000..e66b6955 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i129_source_repeat_single_not_alone.toml @@ -0,0 +1,58 @@ +# Fixture i129_source_repeat_single_not_alone +# Expected: E_SINGLE_MISUSE +# Source: Matrix audit 2026-11-07 (case F17 variant) +# CTRL has the member `single` (no biological-repeat folder) and also the member `rep2`: a member with `source_repeat = "single"` must be the only member of its condition (3.2). +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" + +[conditions.DrugA] +source_folder = "DrugA" +reference = "CTRL" + +[channels.ACTIN] +header_names = ["Actin"] + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[repeats.rep2] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep2.members.CTRL] +source_repeat = "rep2" +image_count = 2 + +[repeats.rep2.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.single] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.single.members.CTRL] +source_repeat = "single" +image_count = 2 + +[repeats.single.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.single.members.DrugA] +source_repeat = "single" +image_count = 2 + +[repeats.single.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep2", "single"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i12_schema_version_string.toml b/tests/spec_fixtures/fixtures/invalid/i12_schema_version_string.toml new file mode 100644 index 00000000..6a84ab05 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i12_schema_version_string.toml @@ -0,0 +1,53 @@ +# Fixture i12_schema_version_string +# Expected: E_TYPE +# Source: Extra: implicit conversion +# schema_version = "1" (a string is not converted to an integer). +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] 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 new file mode 100644 index 00000000..ae788773 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i130_repeat_id_from_forbidden_folder.toml @@ -0,0 +1,46 @@ +# 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). +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" + +[conditions.DrugA] +source_folder = "DrugA" +reference = "CTRL" + +[channels.ACTIN] +header_names = ["Actin"] + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[repeats.z] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.z.members.CTRL] +source_repeat = "z" +image_count = 2 + +[repeats.z.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.z.members.DrugA] +source_repeat = "z" +image_count = 2 + +[repeats.z.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["z"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i13_schema_version_float.toml b/tests/spec_fixtures/fixtures/invalid/i13_schema_version_float.toml new file mode 100644 index 00000000..83dd5c0f --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i13_schema_version_float.toml @@ -0,0 +1,53 @@ +# Fixture i13_schema_version_float +# Expected: E_TYPE +# Source: Extra: implicit conversion +# schema_version = 1.0. +schema = "morphobricks-experiment" +schema_version = 1.0 + +[conditions.CTRL] +source_folder = "CTRL" +display_name = "Control" + +[conditions.DrugA] +source_folder = "DrugA" +display_name = "Drug A" +reference = "CTRL" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i14_schema_version_bool.toml b/tests/spec_fixtures/fixtures/invalid/i14_schema_version_bool.toml new file mode 100644 index 00000000..cc9d47ad --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i14_schema_version_bool.toml @@ -0,0 +1,53 @@ +# Fixture i14_schema_version_bool +# Expected: E_TYPE +# Source: Extra: implicit conversion +# schema_version = true (a boolean is not the integer 1). +schema = "morphobricks-experiment" +schema_version = true + +[conditions.CTRL] +source_folder = "CTRL" +display_name = "Control" + +[conditions.DrugA] +source_folder = "DrugA" +display_name = "Drug A" +reference = "CTRL" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i15_schema_id_wrong.toml b/tests/spec_fixtures/fixtures/invalid/i15_schema_id_wrong.toml new file mode 100644 index 00000000..16be838a --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i15_schema_id_wrong.toml @@ -0,0 +1,53 @@ +# Fixture i15_schema_id_wrong +# Expected: E_SCHEMA_ID +# Source: Extra: versioning +# schema = "morphobricks-experiments". +schema = "morphobricks-experiments" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" +display_name = "Control" + +[conditions.DrugA] +source_folder = "DrugA" +display_name = "Drug A" +reference = "CTRL" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i16_unknown_top_level_field.toml b/tests/spec_fixtures/fixtures/invalid/i16_unknown_top_level_field.toml new file mode 100644 index 00000000..be500c14 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i16_unknown_top_level_field.toml @@ -0,0 +1,54 @@ +# Fixture i16_unknown_top_level_field +# Expected: E_UNKNOWN_FIELD +# Source: Extra: unknown fields +# Top-level `notes`. +schema = "morphobricks-experiment" +schema_version = 1 +notes = "pilot" + +[conditions.CTRL] +source_folder = "CTRL" +display_name = "Control" + +[conditions.DrugA] +source_folder = "DrugA" +display_name = "Drug A" +reference = "CTRL" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i17_unknown_condition_field.toml b/tests/spec_fixtures/fixtures/invalid/i17_unknown_condition_field.toml new file mode 100644 index 00000000..a187efb6 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i17_unknown_condition_field.toml @@ -0,0 +1,54 @@ +# Fixture i17_unknown_condition_field +# Expected: E_UNKNOWN_FIELD +# Source: Extra: unknown fields +# Condition field `colour`. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" +display_name = "Control" +colour = "grey" + +[conditions.DrugA] +source_folder = "DrugA" +display_name = "Drug A" +reference = "CTRL" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i18_misspelled_field.toml b/tests/spec_fixtures/fixtures/invalid/i18_misspelled_field.toml new file mode 100644 index 00000000..5912fde2 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i18_misspelled_field.toml @@ -0,0 +1,53 @@ +# Fixture i18_misspelled_field +# Expected: E_MISSING_FIELD, E_UNKNOWN_FIELD +# Source: Extra: unknown fields +# `representaton` (typo) is not silently ignored; `representation` is then also missing. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representaton = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i19_dotted_float_key.toml b/tests/spec_fixtures/fixtures/invalid/i19_dotted_float_key.toml new file mode 100644 index 00000000..033ebaaa --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i19_dotted_float_key.toml @@ -0,0 +1,59 @@ +# Fixture i19_dotted_float_key +# Expected: E_UNKNOWN_FIELD +# Source: Extra: implicit conversion +# [conditions.1.0] is condition `1` with a sub-table `0`, not condition `1.0`. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[conditions.1] +source_folder = "1" + +[conditions.1.0] +display_name = "one point zero" + +[used_ids] +conditions = ["1", "CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i20_per_fov_override.toml b/tests/spec_fixtures/fixtures/invalid/i20_per_fov_override.toml new file mode 100644 index 00000000..6ddba256 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i20_per_fov_override.toml @@ -0,0 +1,56 @@ +# Fixture i20_per_fov_override +# Expected: E_FORBIDDEN_FIELD +# Source: Extra: within-unit divergence; ADR (no per-FOV overrides) +# A per-FOV channel override inside a technical repeat. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[repeats.rep1.members.DrugA.technical_repeats.single.fovs.FOV003] +channels = ["ACTIN", "DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i21_mask_axes_shared.toml b/tests/spec_fixtures/fixtures/invalid/i21_mask_axes_shared.toml new file mode 100644 index 00000000..a6c66e59 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i21_mask_axes_shared.toml @@ -0,0 +1,55 @@ +# Fixture i21_mask_axes_shared +# Expected: E_FORBIDDEN_FIELD +# Source: Extra: masks; preprocessing #173 +# Mask `axes = "YX"` and `shared = true` (v1 is 2-D only; a mask has its image's axes). +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] +axes = "YX" +shared = true + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i22_brick_routing.toml b/tests/spec_fixtures/fixtures/invalid/i22_brick_routing.toml new file mode 100644 index 00000000..e335cd38 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i22_brick_routing.toml @@ -0,0 +1,54 @@ +# Fixture i22_brick_routing +# Expected: E_FORBIDDEN_FIELD +# Source: Extra: ADR (no brick routing) +# A channel routed to bricks. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" +bricks = ["MorphoRelate", "fiberfeatures"] + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i23_calibration_in_file.toml b/tests/spec_fixtures/fixtures/invalid/i23_calibration_in_file.toml new file mode 100644 index 00000000..c790b799 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i23_calibration_in_file.toml @@ -0,0 +1,54 @@ +# Fixture i23_calibration_in_file +# Expected: E_FORBIDDEN_FIELD +# Source: Extra: calibration stays in calibration.csv +# pixel_size on a repeat. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-06" +pixel_size = 0.325 + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i24_control_boolean.toml b/tests/spec_fixtures/fixtures/invalid/i24_control_boolean.toml new file mode 100644 index 00000000..363b0869 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i24_control_boolean.toml @@ -0,0 +1,54 @@ +# Fixture i24_control_boolean +# Expected: E_FORBIDDEN_FIELD +# Source: Extra: audit §3.3 +# Legacy boolean `control` (derived from `reference` in v1). +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" +display_name = "Control" +control = true + +[conditions.DrugA] +source_folder = "DrugA" +display_name = "Drug A" +reference = "CTRL" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i25_experiment_field.toml b/tests/spec_fixtures/fixtures/invalid/i25_experiment_field.toml new file mode 100644 index 00000000..7068f8d6 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i25_experiment_field.toml @@ -0,0 +1,54 @@ +# Fixture i25_experiment_field +# Expected: E_FORBIDDEN_FIELD +# Source: Extra: audit §3.4 +# Condition `experiment` (an FDR family; MorphoCoverage configuration). +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" +display_name = "Control" + +[conditions.DrugA] +source_folder = "DrugA" +display_name = "Drug A" +experiment = "screen_1" +reference = "CTRL" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i26_source_index_field.toml b/tests/spec_fixtures/fixtures/invalid/i26_source_index_field.toml new file mode 100644 index 00000000..5b3cf7c1 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i26_source_index_field.toml @@ -0,0 +1,54 @@ +# Fixture i26_source_index_field +# Expected: E_FORBIDDEN_FIELD +# Source: Extra: RFC (order is the mapping) +# A unit states `source_index` beside its ordered list. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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"] +source_index = [0, 1] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i27_toml_date_source_repeat.toml b/tests/spec_fixtures/fixtures/invalid/i27_toml_date_source_repeat.toml new file mode 100644 index 00000000..2c6a5177 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i27_toml_date_source_repeat.toml @@ -0,0 +1,53 @@ +# Fixture i27_toml_date_source_repeat +# Expected: E_TYPE +# Source: Extra: implicit conversion (YAML date) +# source_repeat = 2026-01-01 is a TOML date, not text. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep1.members.CTRL] +source_repeat = 2026-01-01 +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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i28_integer_source_repeat.toml b/tests/spec_fixtures/fixtures/invalid/i28_integer_source_repeat.toml new file mode 100644 index 00000000..8eaa9879 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i28_integer_source_repeat.toml @@ -0,0 +1,53 @@ +# Fixture i28_integer_source_repeat +# Expected: E_TYPE +# Source: Extra: implicit conversion +# source_repeat = 12 (integer). +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep1.members.CTRL] +source_repeat = 12 +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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i29_bool_label.toml b/tests/spec_fixtures/fixtures/invalid/i29_bool_label.toml new file mode 100644 index 00000000..5bb7553f --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i29_bool_label.toml @@ -0,0 +1,53 @@ +# Fixture i29_bool_label +# Expected: E_TYPE +# Source: Extra: implicit conversion (YAML `off`) +# display_name = false. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" +display_name = false + +[conditions.DrugA] +source_folder = "DrugA" +display_name = "Drug A" +reference = "CTRL" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i30_confirmed_on_toml_date.toml b/tests/spec_fixtures/fixtures/invalid/i30_confirmed_on_toml_date.toml new file mode 100644 index 00000000..f5ef3816 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i30_confirmed_on_toml_date.toml @@ -0,0 +1,53 @@ +# Fixture i30_confirmed_on_toml_date +# Expected: E_TYPE +# Source: Extra: implicit conversion +# confirmed_on = 2026-11-06 (TOML date literal, not a string). +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i31_confirmed_on_bad_format.toml b/tests/spec_fixtures/fixtures/invalid/i31_confirmed_on_bad_format.toml new file mode 100644 index 00000000..da11ed28 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i31_confirmed_on_bad_format.toml @@ -0,0 +1,53 @@ +# Fixture i31_confirmed_on_bad_format +# Expected: E_DATE_FORMAT +# Source: Extra: preprocessing contract §12 (spreadsheet dates) +# confirmed_on = "06/11/2026". +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[repeats.rep1] +confirmed = true +confirmed_on = "06/11/2026" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i32_confirmed_on_impossible_date.toml b/tests/spec_fixtures/fixtures/invalid/i32_confirmed_on_impossible_date.toml new file mode 100644 index 00000000..149a3c1a --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i32_confirmed_on_impossible_date.toml @@ -0,0 +1,53 @@ +# Fixture i32_confirmed_on_impossible_date +# Expected: E_DATE_FORMAT +# Source: Extra +# confirmed_on = "2026-02-30". +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-02-30" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i33_image_count_string.toml b/tests/spec_fixtures/fixtures/invalid/i33_image_count_string.toml new file mode 100644 index 00000000..c490cfce --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i33_image_count_string.toml @@ -0,0 +1,53 @@ +# Fixture i33_image_count_string +# Expected: E_TYPE +# Source: Extra: implicit conversion +# image_count = "12". +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i34_image_count_float.toml b/tests/spec_fixtures/fixtures/invalid/i34_image_count_float.toml new file mode 100644 index 00000000..fc69be6c --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i34_image_count_float.toml @@ -0,0 +1,53 @@ +# Fixture i34_image_count_float +# Expected: E_TYPE +# Source: Extra: implicit conversion +# image_count = 12.0. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep1.members.CTRL] +source_repeat = "rep1" +image_count = 12.0 + +[repeats.rep1.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i35_image_count_zero.toml b/tests/spec_fixtures/fixtures/invalid/i35_image_count_zero.toml new file mode 100644 index 00000000..9bd2bb78 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i35_image_count_zero.toml @@ -0,0 +1,53 @@ +# Fixture i35_image_count_zero +# Expected: E_VALUE_RANGE +# Source: Extra +# image_count = 0. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep1.members.CTRL] +source_repeat = "rep1" +image_count = 0 + +[repeats.rep1.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i36_confirmed_string.toml b/tests/spec_fixtures/fixtures/invalid/i36_confirmed_string.toml new file mode 100644 index 00000000..43fbf282 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i36_confirmed_string.toml @@ -0,0 +1,53 @@ +# Fixture i36_confirmed_string +# Expected: E_TYPE +# Source: Extra: implicit conversion (legacy yes/no) +# confirmed = "yes". +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[repeats.rep1] +confirmed = "yes" +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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i37_repeat_unconfirmed.toml b/tests/spec_fixtures/fixtures/invalid/i37_repeat_unconfirmed.toml new file mode 100644 index 00000000..67e0c5fd --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i37_repeat_unconfirmed.toml @@ -0,0 +1,53 @@ +# Fixture i37_repeat_unconfirmed +# Expected: E_REPEAT_UNCONFIRMED +# Source: Adversarial 7 (N35) +# A published file with an unconfirmed repeat. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[repeats.rep1] +confirmed = false +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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i38_mixed_type_channel_list.toml b/tests/spec_fixtures/fixtures/invalid/i38_mixed_type_channel_list.toml new file mode 100644 index 00000000..6748cdf9 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i38_mixed_type_channel_list.toml @@ -0,0 +1,53 @@ +# Fixture i38_mixed_type_channel_list +# Expected: E_TYPE +# Source: Extra: implicit conversion +# channels = ["DNA", 2]. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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", 2] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i39_channels_as_string.toml b/tests/spec_fixtures/fixtures/invalid/i39_channels_as_string.toml new file mode 100644 index 00000000..7c4f6532 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i39_channels_as_string.toml @@ -0,0 +1,53 @@ +# Fixture i39_channels_as_string +# Expected: E_TYPE +# Source: Extra +# channels = "DNA,ACTIN" (no splitting of text). +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i40_empty_channel_list.toml b/tests/spec_fixtures/fixtures/invalid/i40_empty_channel_list.toml new file mode 100644 index 00000000..8ec01c90 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i40_empty_channel_list.toml @@ -0,0 +1,53 @@ +# Fixture i40_empty_channel_list +# Expected: E_EMPTY +# Source: Extra +# A unit with channels = []. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = [] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i41_reference_list.toml b/tests/spec_fixtures/fixtures/invalid/i41_reference_list.toml new file mode 100644 index 00000000..d532ea6d --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i41_reference_list.toml @@ -0,0 +1,53 @@ +# Fixture i41_reference_list +# Expected: E_TYPE +# Source: Extra: reference DAG +# Two references in an array; v1 has one primary reference. +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", "DrugA"] + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i42_representation_invalid.toml b/tests/spec_fixtures/fixtures/invalid/i42_representation_invalid.toml new file mode 100644 index 00000000..1749b67b --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i42_representation_invalid.toml @@ -0,0 +1,53 @@ +# Fixture i42_representation_invalid +# Expected: E_ENUM +# Source: Extra: masks +# representation = "labels". +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "labels" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i43_representation_missing.toml b/tests/spec_fixtures/fixtures/invalid/i43_representation_missing.toml new file mode 100644 index 00000000..17895a23 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i43_representation_missing.toml @@ -0,0 +1,52 @@ +# Fixture i43_representation_missing +# Expected: E_MISSING_FIELD +# Source: Extra: masks (never inferred from pixels) +# A mask without representation. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i44_missing_channels_table.toml b/tests/spec_fixtures/fixtures/invalid/i44_missing_channels_table.toml new file mode 100644 index 00000000..529d8430 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i44_missing_channels_table.toml @@ -0,0 +1,46 @@ +# Fixture i44_missing_channels_table +# Expected: E_MISSING_FIELD +# Source: Extra +# No [channels] at all. +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" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i45_member_without_technical_repeats.toml b/tests/spec_fixtures/fixtures/invalid/i45_member_without_technical_repeats.toml new file mode 100644 index 00000000..310f6796 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i45_member_without_technical_repeats.toml @@ -0,0 +1,50 @@ +# Fixture i45_member_without_technical_repeats +# Expected: E_MISSING_FIELD +# Source: Extra +# A member with no technical_repeats. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i46_header_name_empty.toml b/tests/spec_fixtures/fixtures/invalid/i46_header_name_empty.toml new file mode 100644 index 00000000..ae047c9d --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i46_header_name_empty.toml @@ -0,0 +1,53 @@ +# Fixture i46_header_name_empty +# Expected: E_TEXT_EMPTY +# Source: Extra: aliases +# header_names = [""]. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = [""] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i47_label_whitespace_only.toml b/tests/spec_fixtures/fixtures/invalid/i47_label_whitespace_only.toml new file mode 100644 index 00000000..8b0b1567 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i47_label_whitespace_only.toml @@ -0,0 +1,53 @@ +# Fixture i47_label_whitespace_only +# Expected: E_TEXT_EMPTY +# Source: Extra +# display_name = " ". +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" +display_name = " " + +[conditions.DrugA] +source_folder = "DrugA" +display_name = "Drug A" +reference = "CTRL" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i48_case_collision_conditions.toml b/tests/spec_fixtures/fixtures/invalid/i48_case_collision_conditions.toml new file mode 100644 index 00000000..421f0ad0 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i48_case_collision_conditions.toml @@ -0,0 +1,64 @@ +# Fixture i48_case_collision_conditions +# Expected: E_ID_CASE_COLLISION +# Source: Extra: case collisions +# Conditions CTRL and ctrl. +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" + +[conditions.ctrl] +source_folder = "ctrl" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[repeats.rep1.members.ctrl] +source_repeat = "rep1" +image_count = 4 + +[repeats.rep1.members.ctrl.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[used_ids] +conditions = ["CTRL", "DrugA", "ctrl"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i49_case_collision_channels.toml b/tests/spec_fixtures/fixtures/invalid/i49_case_collision_channels.toml new file mode 100644 index 00000000..5bdc78b4 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i49_case_collision_channels.toml @@ -0,0 +1,55 @@ +# Fixture i49_case_collision_channels +# Expected: E_ID_CASE_COLLISION +# Source: Extra: case collisions +# Channels DNA and dna. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[channels.dna] + +[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", "dna"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA", "dna"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i50_case_collision_source_repeats.toml b/tests/spec_fixtures/fixtures/invalid/i50_case_collision_source_repeats.toml new file mode 100644 index 00000000..91857936 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i50_case_collision_source_repeats.toml @@ -0,0 +1,53 @@ +# Fixture i50_case_collision_source_repeats +# Expected: E_ID_CASE_COLLISION +# Source: Extra: case collisions; preprocessing check_identity_case +# Source folders Rep1 (CTRL) and rep1 (DrugA): a label is spelled one way in the whole run. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i51_case_collision_repeat_ids.toml b/tests/spec_fixtures/fixtures/invalid/i51_case_collision_repeat_ids.toml new file mode 100644 index 00000000..33ab79ad --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i51_case_collision_repeat_ids.toml @@ -0,0 +1,65 @@ +# Fixture i51_case_collision_repeat_ids +# Expected: E_ID_CASE_COLLISION +# Source: Extra: case collisions +# Canonical repeats rep1 and REP1. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[repeats.REP1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.REP1.members.CTRL] +source_repeat = "rep2" +image_count = 4 + +[repeats.REP1.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["REP1", "rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i52_case_collision_technical_repeats.toml b/tests/spec_fixtures/fixtures/invalid/i52_case_collision_technical_repeats.toml new file mode 100644 index 00000000..571c0265 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i52_case_collision_technical_repeats.toml @@ -0,0 +1,53 @@ +# Fixture i52_case_collision_technical_repeats +# Expected: E_ID_CASE_COLLISION +# Source: Extra: case collisions +# Technical repeats acq_1 and Acq_1 in different members. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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.acq_1] +source_folder = "acq_1" +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugA.technical_repeats.Acq_1] +source_folder = "Acq_1" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["Acq_1", "acq_1"] diff --git a/tests/spec_fixtures/fixtures/invalid/i53_display_name_collision_conditions.toml b/tests/spec_fixtures/fixtures/invalid/i53_display_name_collision_conditions.toml new file mode 100644 index 00000000..81c3f54c --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i53_display_name_collision_conditions.toml @@ -0,0 +1,71 @@ +# Fixture i53_display_name_collision_conditions +# Expected: E_DISPLAY_NAME_COLLISION +# Source: Owner refinement (6 Nov 2026) +# Conditions CTRL and DrugA both have display_name "Control". Display names are unique within their kind, though never identity. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" +display_name = "Control" + +[conditions.DrugA] +source_folder = "DrugA" +display_name = "Control" +reference = "CTRL" +target = "SRC" +treatment_key = "saracatinib" + +[channels.DNA] +display_name = "DAPI" +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/invalid/i54_channel_id_missing_text.toml b/tests/spec_fixtures/fixtures/invalid/i54_channel_id_missing_text.toml new file mode 100644 index 00000000..e5d14b56 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i54_channel_id_missing_text.toml @@ -0,0 +1,53 @@ +# Fixture i54_channel_id_missing_text +# Expected: E_ID_TEXT +# Source: Extra: identities (decision D-e) +# Channel ID `NA` (tables read it as missing). +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.NA] +display_name = "F-actin" + +[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", "NA"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "NA"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["DNA", "NA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i55_condition_outer_whitespace.toml b/tests/spec_fixtures/fixtures/invalid/i55_condition_outer_whitespace.toml new file mode 100644 index 00000000..3e652400 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i55_condition_outer_whitespace.toml @@ -0,0 +1,53 @@ +# Fixture i55_condition_outer_whitespace +# Expected: E_ID_TEXT +# Source: Extra: identities (decision D-e) +# Condition key " CTRL" with a leading space. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = [" CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i56_source_repeat_null.toml b/tests/spec_fixtures/fixtures/invalid/i56_source_repeat_null.toml new file mode 100644 index 00000000..80e99984 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i56_source_repeat_null.toml @@ -0,0 +1,53 @@ +# Fixture i56_source_repeat_null +# Expected: E_ID_TEXT +# Source: Extra: identities +# source_repeat = "null". +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep1.members.CTRL] +source_repeat = "null" +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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i57_channel_id_hyphen.toml b/tests/spec_fixtures/fixtures/invalid/i57_channel_id_hyphen.toml new file mode 100644 index 00000000..f35fd794 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i57_channel_id_hyphen.toml @@ -0,0 +1,53 @@ +# Fixture i57_channel_id_hyphen +# Expected: E_ID_SYNTAX +# Source: Extra: identities (token rule) +# Channel ID F-actin. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.F-actin] +display_name = "F-actin" + +[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", "F-actin"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "F-actin"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["DNA", "F-actin"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i58_channel_id_double_underscore.toml b/tests/spec_fixtures/fixtures/invalid/i58_channel_id_double_underscore.toml new file mode 100644 index 00000000..8edd2cae --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i58_channel_id_double_underscore.toml @@ -0,0 +1,53 @@ +# Fixture i58_channel_id_double_underscore +# Expected: E_ID_SYNTAX +# Source: Extra: identities (feature-column separator) +# Channel ID DNA__2. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.DNA__2] +display_name = "F-actin" + +[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", "DNA__2"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "DNA__2"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["DNA", "DNA__2"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i59_mask_id_reserved.toml b/tests/spec_fixtures/fixtures/invalid/i59_mask_id_reserved.toml new file mode 100644 index 00000000..a6def800 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i59_mask_id_reserved.toml @@ -0,0 +1,53 @@ +# Fixture i59_mask_id_reserved +# Expected: E_ID_RESERVED +# Source: Extra: MorphoFeature object-set rule +# Mask ID `Nucleus` (reserved compartment, any case). +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nucleus] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i60_mask_id_reserved_in.toml b/tests/spec_fixtures/fixtures/invalid/i60_mask_id_reserved_in.toml new file mode 100644 index 00000000..550c13da --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i60_mask_id_reserved_in.toml @@ -0,0 +1,53 @@ +# Fixture i60_mask_id_reserved_in +# Expected: E_ID_RESERVED +# Source: Extra: MorphoFeature object-set rule +# Mask ID `Cells_in_Tumor` (an `in` part). +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Cells_in_Tumor] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i61_channel_id_reserved.toml b/tests/spec_fixtures/fixtures/invalid/i61_channel_id_reserved.toml new file mode 100644 index 00000000..ebffee30 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i61_channel_id_reserved.toml @@ -0,0 +1,53 @@ +# Fixture i61_channel_id_reserved +# Expected: E_ID_RESERVED +# Source: Extra: audit §3.8 +# Channel ID `Ring` (MorphoRelate compartment token). +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.Ring] +display_name = "F-actin" + +[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", "Ring"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "Ring"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["DNA", "Ring"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i62_single_wrong_case.toml b/tests/spec_fixtures/fixtures/invalid/i62_single_wrong_case.toml new file mode 100644 index 00000000..adf113a4 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i62_single_wrong_case.toml @@ -0,0 +1,53 @@ +# Fixture i62_single_wrong_case +# Expected: E_SINGLE_MISUSE +# Source: Extra: single convention +# Technical repeat `Single` (spelled one way, so no case collision). +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.Single] +source_folder = "Single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["Single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i63_single_not_alone.toml b/tests/spec_fixtures/fixtures/invalid/i63_single_not_alone.toml new file mode 100644 index 00000000..4f781fe5 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i63_single_not_alone.toml @@ -0,0 +1,57 @@ +# Fixture i63_single_not_alone +# Expected: E_SINGLE_MISUSE +# Source: Extra: single convention +# Technical repeat `single` beside `acquisition_2`. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[repeats.rep1.members.CTRL.technical_repeats.acquisition_2] +source_folder = "acquisition_2" +channels = ["DNA", "ACTIN"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["acquisition_2", "single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i64_reference_cycle.toml b/tests/spec_fixtures/fixtures/invalid/i64_reference_cycle.toml new file mode 100644 index 00000000..5bef9821 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i64_reference_cycle.toml @@ -0,0 +1,54 @@ +# Fixture i64_reference_cycle +# Expected: E_REFERENCE_CYCLE +# Source: RFC case 12 +# CTRL -> DrugA -> CTRL. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" +display_name = "Control" +reference = "DrugA" + +[conditions.DrugA] +source_folder = "DrugA" +display_name = "Drug A" +reference = "CTRL" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i65_reference_cycle_long.toml b/tests/spec_fixtures/fixtures/invalid/i65_reference_cycle_long.toml new file mode 100644 index 00000000..520ebf31 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i65_reference_cycle_long.toml @@ -0,0 +1,66 @@ +# Fixture i65_reference_cycle_long +# Expected: E_REFERENCE_CYCLE +# Source: RFC case 12 +# CTRL -> DrugB -> DrugA -> CTRL. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" +display_name = "Control" +reference = "DrugB" + +[conditions.DrugA] +source_folder = "DrugA" +display_name = "Drug A" +reference = "CTRL" + +[conditions.DrugB] +source_folder = "DrugB" +reference = "DrugA" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[repeats.rep1.members.DrugB] +source_repeat = "rep1" +image_count = 4 + +[repeats.rep1.members.DrugB.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[used_ids] +conditions = ["CTRL", "DrugA", "DrugB"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i66_reference_self.toml b/tests/spec_fixtures/fixtures/invalid/i66_reference_self.toml new file mode 100644 index 00000000..e815aa0b --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i66_reference_self.toml @@ -0,0 +1,53 @@ +# Fixture i66_reference_self +# Expected: E_REFERENCE_SELF +# Source: Extra: reference DAG +# DrugA references itself. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" +display_name = "Control" + +[conditions.DrugA] +source_folder = "DrugA" +display_name = "Drug A" +reference = "DrugA" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i67_reference_unknown.toml b/tests/spec_fixtures/fixtures/invalid/i67_reference_unknown.toml new file mode 100644 index 00000000..9da25e66 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i67_reference_unknown.toml @@ -0,0 +1,53 @@ +# Fixture i67_reference_unknown +# Expected: E_REFERENCE_UNKNOWN +# Source: Extra: reference DAG +# Reference to undeclared DMSO. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" +display_name = "Control" + +[conditions.DrugA] +source_folder = "DrugA" +display_name = "Drug A" +reference = "DMSO" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i68_reference_case_mismatch.toml b/tests/spec_fixtures/fixtures/invalid/i68_reference_case_mismatch.toml new file mode 100644 index 00000000..162aa895 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i68_reference_case_mismatch.toml @@ -0,0 +1,53 @@ +# Fixture i68_reference_case_mismatch +# Expected: E_REFERENCE_UNKNOWN +# Source: Extra: case rules (no folding on resolution) +# reference = "ctrl" does not resolve to CTRL. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i69_unknown_channel_in_unit.toml b/tests/spec_fixtures/fixtures/invalid/i69_unknown_channel_in_unit.toml new file mode 100644 index 00000000..739d9a29 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i69_unknown_channel_in_unit.toml @@ -0,0 +1,53 @@ +# Fixture i69_unknown_channel_in_unit +# Expected: E_UNKNOWN_CHANNEL +# Source: Extra: extra channel not declared +# A unit lists PHASE, which is not declared. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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", "PHASE"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i70_duplicate_channel_in_unit.toml b/tests/spec_fixtures/fixtures/invalid/i70_duplicate_channel_in_unit.toml new file mode 100644 index 00000000..04fd2ff9 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i70_duplicate_channel_in_unit.toml @@ -0,0 +1,53 @@ +# Fixture i70_duplicate_channel_in_unit +# Expected: E_DUPLICATE_CHANNEL_IN_UNIT +# Source: Extra +# channels = ["DNA", "ACTIN", "DNA"]. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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", "DNA"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i71_target_not_text.toml b/tests/spec_fixtures/fixtures/invalid/i71_target_not_text.toml new file mode 100644 index 00000000..3cfdc326 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i71_target_not_text.toml @@ -0,0 +1,71 @@ +# Fixture i71_target_not_text +# Expected: E_TYPE +# Source: Owner decision 1 (6 Nov 2026) +# target = 5: `target` and `treatment_key` are plain strings, never converted. +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 = 5 +treatment_key = "saracatinib" + +[channels.DNA] +display_name = "DAPI" +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/invalid/i72_member_unknown_condition.toml b/tests/spec_fixtures/fixtures/invalid/i72_member_unknown_condition.toml new file mode 100644 index 00000000..4a8e8116 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i72_member_unknown_condition.toml @@ -0,0 +1,61 @@ +# Fixture i72_member_unknown_condition +# Expected: E_UNKNOWN_CONDITION +# Source: Extra +# A member for undeclared condition DrugB. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[repeats.rep1.members.DrugB] +source_repeat = "rep1" +image_count = 4 + +[repeats.rep1.members.DrugB.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i73_condition_without_members.toml b/tests/spec_fixtures/fixtures/invalid/i73_condition_without_members.toml new file mode 100644 index 00000000..acdf859d --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i73_condition_without_members.toml @@ -0,0 +1,57 @@ +# Fixture i73_condition_without_members +# Expected: E_CONDITION_WITHOUT_MEMBERS +# Source: Extra +# Condition DrugB declared, no member. +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" + +[conditions.DrugB] +source_folder = "DrugB" +reference = "DrugA" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA", "DrugB"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i74_source_folder_in_two_repeats.toml b/tests/spec_fixtures/fixtures/invalid/i74_source_folder_in_two_repeats.toml new file mode 100644 index 00000000..b3cab042 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i74_source_folder_in_two_repeats.toml @@ -0,0 +1,77 @@ +# Fixture i74_source_folder_in_two_repeats +# Expected: E_SOURCE_REPEAT_REUSED +# Source: Extra: repeat pairing +# Source folder CTRL/exp7 belongs to repeats Day2 and Day3. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[repeats.Day2] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.Day2.members.CTRL] +source_repeat = "exp7" +image_count = 6 + +[repeats.Day2.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.Day3] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.Day3.members.CTRL] +source_repeat = "exp7" +image_count = 6 + +[repeats.Day3.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["Day2", "Day3", "rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i75_repeat_id_swap.toml b/tests/spec_fixtures/fixtures/invalid/i75_repeat_id_swap.toml new file mode 100644 index 00000000..48cd5710 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i75_repeat_id_swap.toml @@ -0,0 +1,65 @@ +# Fixture i75_repeat_id_swap +# Expected: E_REPEAT_ID_SHADOWS_SOURCE +# Source: Extra: renames +# Repeat rep1 holds folder rep2 and repeat rep2 holds folder rep1 for CTRL. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep1.members.CTRL] +source_repeat = "rep2" +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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[repeats.rep2] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep2.members.CTRL] +source_repeat = "rep1" +image_count = 7 + +[repeats.rep2.members.CTRL.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/invalid/i76_display_name_collision_repeats.toml b/tests/spec_fixtures/fixtures/invalid/i76_display_name_collision_repeats.toml new file mode 100644 index 00000000..79108288 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i76_display_name_collision_repeats.toml @@ -0,0 +1,73 @@ +# Fixture i76_display_name_collision_repeats +# Expected: E_DISPLAY_NAME_COLLISION +# Source: Owner refinement (6 Nov 2026) +# Repeats rep1 and rep2 both display as "Day 1". +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" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" +header_names = ["Phalloidin-488"] + +[repeats.rep1] +display_name = "Day 1" +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] +display_name = "Day 1" +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/invalid/i77_display_name_collision_channels.toml b/tests/spec_fixtures/fixtures/invalid/i77_display_name_collision_channels.toml new file mode 100644 index 00000000..863bf4df --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i77_display_name_collision_channels.toml @@ -0,0 +1,71 @@ +# Fixture i77_display_name_collision_channels +# Expected: E_DISPLAY_NAME_COLLISION +# Source: Owner refinement (6 Nov 2026) +# Channels DNA and ACTIN both display as "DAPI". +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" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "DAPI" +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/invalid/i78_non_nfc_condition_key.toml b/tests/spec_fixtures/fixtures/invalid/i78_non_nfc_condition_key.toml new file mode 100644 index 00000000..b34e9485 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i78_non_nfc_condition_key.toml @@ -0,0 +1,71 @@ +# Fixture i78_non_nfc_condition_key +# Expected: E_ID_NOT_NFC +# Source: Owner decisions of 6 Nov 2026 (Q3) +# The condition key is "Cafe\u0301" (decomposed). A reader refuses it, reporting each non-NFC identity (key, references, members, used_ids); it never normalizes, so there is no NFC collision case. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions."Cafe\u0301"] +source_folder = "Cafe\u0301" +display_name = "Control" + +[conditions.DrugA] +source_folder = "DrugA" +display_name = "Drug A" +reference = "Cafe\u0301" +target = "SRC" +treatment_key = "saracatinib" + +[channels.DNA] +display_name = "DAPI" +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."Cafe\u0301"] +source_repeat = "rep1" +image_count = 12 + +[repeats.rep1.members."Cafe\u0301".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."Cafe\u0301"] +source_repeat = "rep2" +image_count = 10 + +[repeats.rep2.members."Cafe\u0301".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 = ["Cafe\u0301", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1", "rep2"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i79_mask_source_channel_unknown.toml b/tests/spec_fixtures/fixtures/invalid/i79_mask_source_channel_unknown.toml new file mode 100644 index 00000000..f47f10d0 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i79_mask_source_channel_unknown.toml @@ -0,0 +1,53 @@ +# Fixture i79_mask_source_channel_unknown +# Expected: E_UNKNOWN_CHANNEL +# Source: Extra: masks +# source_channels names undeclared Hoechst. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["Hoechst"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i80_mask_source_type_reused.toml b/tests/spec_fixtures/fixtures/invalid/i80_mask_source_type_reused.toml new file mode 100644 index 00000000..3909d603 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i80_mask_source_type_reused.toml @@ -0,0 +1,57 @@ +# Fixture i80_mask_source_type_reused +# Expected: E_MASK_SOURCE_TYPE_REUSED +# Source: Extra: masks +# Two masks with source_type nuclei. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[masks.Nuclei2] +source_type = "nuclei" +representation = "binary" + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i81_header_names_duplicate.toml b/tests/spec_fixtures/fixtures/invalid/i81_header_names_duplicate.toml new file mode 100644 index 00000000..05c33479 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i81_header_names_duplicate.toml @@ -0,0 +1,53 @@ +# Fixture i81_header_names_duplicate +# Expected: E_DUPLICATE_VALUE +# Source: Extra: aliases +# header_names = ["DAPI", "DAPI"]. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI", "DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i82_retired_repeats_forbidden.toml b/tests/spec_fixtures/fixtures/invalid/i82_retired_repeats_forbidden.toml new file mode 100644 index 00000000..e6585560 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i82_retired_repeats_forbidden.toml @@ -0,0 +1,75 @@ +# Fixture i82_retired_repeats_forbidden +# Expected: E_FORBIDDEN_FIELD +# Source: Owner refinement (6 Nov 2026) +# A `retired_repeats` rename table: IDs are immutable and are never renamed, so v1 has no such table. +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" +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"] + +[retired_repeats.rep0] +retired_on = "2026-11-01" +replaced_by = "rep1" + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1", "rep2"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i83_non_nfc_reference.toml b/tests/spec_fixtures/fixtures/invalid/i83_non_nfc_reference.toml new file mode 100644 index 00000000..3db8ad50 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i83_non_nfc_reference.toml @@ -0,0 +1,71 @@ +# Fixture i83_non_nfc_reference +# Expected: E_ID_NOT_NFC +# Source: Owner decisions of 6 Nov 2026 (Q3) +# The condition key is composed but DrugA references it decomposed. Not E_REFERENCE_UNKNOWN: phase 4 stops first, and the reader does not normalize to find a match. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions."Caf\u00e9"] +source_folder = "Caf\u00e9" +display_name = "Control" + +[conditions.DrugA] +source_folder = "DrugA" +display_name = "Drug A" +reference = "Cafe\u0301" +target = "SRC" +treatment_key = "saracatinib" + +[channels.DNA] +display_name = "DAPI" +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."Caf\u00e9"] +source_repeat = "rep1" +image_count = 12 + +[repeats.rep1.members."Caf\u00e9".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."Caf\u00e9"] +source_repeat = "rep2" +image_count = 10 + +[repeats.rep2.members."Caf\u00e9".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 = ["Caf\u00e9", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1", "rep2"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i84_non_nfc_source_repeat.toml b/tests/spec_fixtures/fixtures/invalid/i84_non_nfc_source_repeat.toml new file mode 100644 index 00000000..23295bea --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i84_non_nfc_source_repeat.toml @@ -0,0 +1,71 @@ +# Fixture i84_non_nfc_source_repeat +# Expected: E_ID_NOT_NFC +# Source: Owner decisions of 6 Nov 2026 (Q3) +# source_repeat "re\u0301p1" is decomposed: source identities are refused too, never normalized. +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" +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 = "re\u0301p1" +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/invalid/i85_non_nfc_used_id.toml b/tests/spec_fixtures/fixtures/invalid/i85_non_nfc_used_id.toml new file mode 100644 index 00000000..6097180e --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i85_non_nfc_used_id.toml @@ -0,0 +1,71 @@ +# Fixture i85_non_nfc_used_id +# Expected: E_ID_NOT_NFC +# Source: Owner decisions of 6 Nov 2026 (Q1, Q3) +# A retired condition ID listed in used_ids is decomposed: the registry holds identities and is held to the same NFC rule. +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" +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", "Cafe\u0301"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1", "rep2"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i86_display_name_collision_case.toml b/tests/spec_fixtures/fixtures/invalid/i86_display_name_collision_case.toml new file mode 100644 index 00000000..7b808db0 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i86_display_name_collision_case.toml @@ -0,0 +1,71 @@ +# Fixture i86_display_name_collision_case +# Expected: E_DISPLAY_NAME_COLLISION +# Source: Owner decisions of 6 Nov 2026 (Q2) +# Condition display names "Control" and "control" collide: uniqueness is decided after NFC and Unicode case folding. Names are still kept as typed. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" +display_name = "Control" + +[conditions.DrugA] +source_folder = "DrugA" +display_name = "control" +reference = "CTRL" +target = "SRC" +treatment_key = "saracatinib" + +[channels.DNA] +display_name = "DAPI" +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/invalid/i87_display_name_collision_casefold.toml b/tests/spec_fixtures/fixtures/invalid/i87_display_name_collision_casefold.toml new file mode 100644 index 00000000..4c49fb55 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i87_display_name_collision_casefold.toml @@ -0,0 +1,71 @@ +# Fixture i87_display_name_collision_casefold +# Expected: E_DISPLAY_NAME_COLLISION +# Source: Owner decisions of 6 Nov 2026 (Q2) +# Channel display names "Stra\u00dfe" and "STRASSE" collide because full case folding maps the sharp s to "ss" (a lower-case-only comparison would miss it). +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 = "Stra\u00dfe" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "STRASSE" +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/invalid/i88_display_name_collision_nfc_and_case.toml b/tests/spec_fixtures/fixtures/invalid/i88_display_name_collision_nfc_and_case.toml new file mode 100644 index 00000000..ad1b6265 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i88_display_name_collision_nfc_and_case.toml @@ -0,0 +1,73 @@ +# Fixture i88_display_name_collision_nfc_and_case +# Expected: E_DISPLAY_NAME_COLLISION +# Source: Owner decisions of 6 Nov 2026 (Q2) +# Repeat display names "Cafe\u0301 1" (decomposed) and "CAF\u00c9 1" (composed, upper case) collide only when NFC and case folding are both applied. +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" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" +header_names = ["Phalloidin-488"] + +[repeats.rep1] +display_name = "Cafe\u0301 1" +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] +display_name = "CAF\u00c9 1" +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/invalid/i89_reference_names_display_name.toml b/tests/spec_fixtures/fixtures/invalid/i89_reference_names_display_name.toml new file mode 100644 index 00000000..a266cd98 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i89_reference_names_display_name.toml @@ -0,0 +1,71 @@ +# Fixture i89_reference_names_display_name +# Expected: E_REFERENCE_UNKNOWN +# Source: LabChronicle audit L1; Owner decisions of 6 Nov 2026 +# DrugA references "Control", which is the display_name of CTRL, not a condition ID. A display name never substitutes for an ID, even though it is unique. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" +display_name = "Control" + +[conditions.DrugA] +source_folder = "DrugA" +display_name = "Drug A" +reference = "Control" +target = "SRC" +treatment_key = "saracatinib" + +[channels.DNA] +display_name = "DAPI" +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/invalid/i90_member_keyed_by_display_name.toml b/tests/spec_fixtures/fixtures/invalid/i90_member_keyed_by_display_name.toml new file mode 100644 index 00000000..1e6d6fe0 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i90_member_keyed_by_display_name.toml @@ -0,0 +1,71 @@ +# Fixture i90_member_keyed_by_display_name +# Expected: E_UNKNOWN_CONDITION +# Source: LabChronicle audit L1 +# A member is keyed by the display name "Control" instead of the condition ID CTRL (CTRL still has a member in rep2). +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" +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."Control"] +source_repeat = "rep1" +image_count = 12 + +[repeats.rep1.members."Control".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/invalid/i91_unit_lists_channel_display_name.toml b/tests/spec_fixtures/fixtures/invalid/i91_unit_lists_channel_display_name.toml new file mode 100644 index 00000000..19cf8bf4 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i91_unit_lists_channel_display_name.toml @@ -0,0 +1,71 @@ +# Fixture i91_unit_lists_channel_display_name +# Expected: E_UNKNOWN_CHANNEL +# Source: LabChronicle audit L1 +# A unit lists "DAPI", the display_name of channel DNA, instead of the channel ID. +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" +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 = ["DAPI", "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/invalid/i92_used_ids_missing.toml b/tests/spec_fixtures/fixtures/invalid/i92_used_ids_missing.toml new file mode 100644 index 00000000..fcc0c9fe --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i92_used_ids_missing.toml @@ -0,0 +1,65 @@ +# Fixture i92_used_ids_missing +# Expected: E_MISSING_FIELD +# Source: Owner decisions of 6 Nov 2026 (Q1) +# The required used_ids table is absent. +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" +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"] diff --git a/tests/spec_fixtures/fixtures/invalid/i93_used_id_not_listed.toml b/tests/spec_fixtures/fixtures/invalid/i93_used_id_not_listed.toml new file mode 100644 index 00000000..78c3cbae --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i93_used_id_not_listed.toml @@ -0,0 +1,71 @@ +# Fixture i93_used_id_not_listed +# Expected: E_USED_ID_MISSING +# Source: Owner decisions of 6 Nov 2026 (Q1) +# DrugA is declared but absent from used_ids.conditions: every declared ID MUST be listed. +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" +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"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1", "rep2"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i94_used_ids_duplicate.toml b/tests/spec_fixtures/fixtures/invalid/i94_used_ids_duplicate.toml new file mode 100644 index 00000000..d8bb23d3 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i94_used_ids_duplicate.toml @@ -0,0 +1,71 @@ +# Fixture i94_used_ids_duplicate +# Expected: E_DUPLICATE_VALUE +# Source: Owner decisions of 6 Nov 2026 (Q1) +# rep1 is listed twice in used_ids.repeats. +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" +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", "rep1", "rep2"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i95_used_id_case_collision.toml b/tests/spec_fixtures/fixtures/invalid/i95_used_id_case_collision.toml new file mode 100644 index 00000000..a49157d9 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i95_used_id_case_collision.toml @@ -0,0 +1,71 @@ +# Fixture i95_used_id_case_collision +# Expected: E_ID_CASE_COLLISION +# Source: LabChronicle audit B2, B11; Owner decisions of 6 Nov 2026 (Q1) +# The retired ID "ctrl" in used_ids collides ignoring case with the declared CTRL: a retired ID cannot return re-cased. +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" +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", "ctrl"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1", "rep2"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/invalid/i96_used_ids_mapping.toml b/tests/spec_fixtures/fixtures/invalid/i96_used_ids_mapping.toml new file mode 100644 index 00000000..09df3b71 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i96_used_ids_mapping.toml @@ -0,0 +1,74 @@ +# Fixture i96_used_ids_mapping +# Expected: E_TYPE +# Source: Owner decisions of 6 Nov 2026 (Q1) +# used_ids.repeats is a table mapping old to new IDs. The registry is a list of IDs only; a mapping would be a rename table (see i82). +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" +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"] +technical_repeats = ["single"] + +[used_ids.repeats] +rep1 = "Day1" +rep2 = "Day2" diff --git a/tests/spec_fixtures/fixtures/invalid/i97_used_ids_unknown_kind.toml b/tests/spec_fixtures/fixtures/invalid/i97_used_ids_unknown_kind.toml new file mode 100644 index 00000000..ed03d1fc --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i97_used_ids_unknown_kind.toml @@ -0,0 +1,72 @@ +# Fixture i97_used_ids_unknown_kind +# Expected: E_UNKNOWN_FIELD +# Source: Owner decisions of 6 Nov 2026 (Q1) +# used_ids has the kinds conditions, channels and repeats only; masks are not immutable identities in v1. +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" +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"] +masks = ["Nuclei"] diff --git a/tests/spec_fixtures/fixtures/invalid/i98_confirmed_on_missing.toml b/tests/spec_fixtures/fixtures/invalid/i98_confirmed_on_missing.toml new file mode 100644 index 00000000..c28b1ff6 --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i98_confirmed_on_missing.toml @@ -0,0 +1,70 @@ +# Fixture i98_confirmed_on_missing +# Expected: E_MISSING_FIELD +# Source: LabChronicle audit L8, B5 +# confirmed_on is absent from rep1. It is a user fact: a reader supplies no default (not the read date, not the write date, not another repeat's date). +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" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" +header_names = ["Phalloidin-488"] + +[repeats.rep1] +confirmed = true + +[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/invalid/i99_labchronicle_provenance.toml b/tests/spec_fixtures/fixtures/invalid/i99_labchronicle_provenance.toml new file mode 100644 index 00000000..9780a0ee --- /dev/null +++ b/tests/spec_fixtures/fixtures/invalid/i99_labchronicle_provenance.toml @@ -0,0 +1,75 @@ +# Fixture i99_labchronicle_provenance +# Expected: E_FORBIDDEN_FIELD +# Source: Owner decisions of 6 Nov 2026 (Q5) +# A top-level labchronicle table. LabChronicle links live in the run manifest provenance (preprocessing) only. A forbidden table is reported once at its name; its contents are not examined. +schema = "morphobricks-experiment" +schema_version = 1 + +[labchronicle] +repository = "https://example.org/lab/database" +repeat_code = "R001" + +[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" +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/transitions/t01_repeat_id_changed.toml b/tests/spec_fixtures/fixtures/transitions/t01_repeat_id_changed.toml new file mode 100644 index 00000000..c37b95ed --- /dev/null +++ b/tests/spec_fixtures/fixtures/transitions/t01_repeat_id_changed.toml @@ -0,0 +1,71 @@ +# Fixture t01_repeat_id_changed +# Expected: R_CONFIRMED_ID_CHANGED (against predecessor valid/v01, without a recorded re-confirmation) +# Source: Owner refinement (6 Nov 2026) +# Valid on its own, but the repeat with members CTRL/rep1 and DrugA/rep1 was confirmed as `rep1` in v01 and is now `Day1` (likewise rep2 -> Day2). A confirmed ID is never changed in place; display_name is the field for a friendlier name. Accepted only with an explicit, recorded re-confirmation (e.g. a mistyped ID). rep1 and rep2 stay listed in used_ids (append-only, experiment-v1 4.9), so the candidate is refused only for the changed ID. +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" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" +header_names = ["Phalloidin-488"] + +[repeats.Day1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.Day1.members.CTRL] +source_repeat = "rep1" +image_count = 12 + +[repeats.Day1.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.Day1.members.DrugA] +source_repeat = "rep1" +image_count = 11 + +[repeats.Day1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.Day2] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.Day2.members.CTRL] +source_repeat = "rep2" +image_count = 10 + +[repeats.Day2.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.Day2.members.DrugA] +source_repeat = "rep2" +image_count = 10 + +[repeats.Day2.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["Day1", "Day2", "rep1", "rep2"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/transitions/t02_channel_id_changed.toml b/tests/spec_fixtures/fixtures/transitions/t02_channel_id_changed.toml new file mode 100644 index 00000000..5a04f48d --- /dev/null +++ b/tests/spec_fixtures/fixtures/transitions/t02_channel_id_changed.toml @@ -0,0 +1,71 @@ +# Fixture t02_channel_id_changed +# Expected: R_CONFIRMED_ID_CHANGED, R_USED_ID_DROPPED (against predecessor valid/v01, without a recorded re-confirmation) +# Source: Owner refinement (6 Nov 2026) +# Valid on its own, but channel ACTIN of v01 (same header evidence, same unit positions) is now `Actin`. Confirmed channel IDs are immutable; only display_name may change. ACTIN is also no longer listed in used_ids (R_USED_ID_DROPPED); keeping it listed beside `Actin` would make the file itself invalid (E_ID_CASE_COLLISION, experiment-v1 4.9), so a case-only change of a confirmed ID can never be published. +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" +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/transitions/t03_used_id_reused.toml b/tests/spec_fixtures/fixtures/transitions/t03_used_id_reused.toml new file mode 100644 index 00000000..8efd1c79 --- /dev/null +++ b/tests/spec_fixtures/fixtures/transitions/t03_used_id_reused.toml @@ -0,0 +1,61 @@ +# Fixture t03_used_id_reused +# Expected: R_USED_ID_REUSED (against predecessor valid/v25) +# Source: Owner decisions of 6 Nov 2026 (Q1); LabChronicle audit L2 +# Valid on its own, but DrugA was retired in v25 (listed in used_ids, not declared) and is declared again here for a new source folder. A listed ID is never reused, even by the same folder returning: it needs a new ID. Repeat rep1 gains the member DrugA/new_folder_1; adding a member to an existing repeat is not a changed entity (experiment-v1 4.6, owner decision AQ1), so the set is exactly {R_USED_ID_REUSED}, not R_CONFIRMED_ID_CHANGED. +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" + +[channels.DNA] +display_name = "DAPI" +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.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.rep1.members.DrugA] +source_repeat = "new_folder_1" +image_count = 4 + +[repeats.rep1.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/transitions/t04_used_id_dropped.toml b/tests/spec_fixtures/fixtures/transitions/t04_used_id_dropped.toml new file mode 100644 index 00000000..1f98f120 --- /dev/null +++ b/tests/spec_fixtures/fixtures/transitions/t04_used_id_dropped.toml @@ -0,0 +1,48 @@ +# Fixture t04_used_id_dropped +# Expected: R_USED_ID_DROPPED (against predecessor valid/v25) +# Source: Owner decisions of 6 Nov 2026 (Q1) +# Valid on its own, but v25 listed DrugA in used_ids.conditions and this file no longer does. used_ids is append-only: an entry is never removed. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" +display_name = "Control" + +[channels.DNA] +display_name = "DAPI" +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.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"] + +[used_ids] +conditions = ["CTRL"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1", "rep2"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/transitions/t05_confirmed_on_changed.toml b/tests/spec_fixtures/fixtures/transitions/t05_confirmed_on_changed.toml new file mode 100644 index 00000000..511adf0b --- /dev/null +++ b/tests/spec_fixtures/fixtures/transitions/t05_confirmed_on_changed.toml @@ -0,0 +1,71 @@ +# Fixture t05_confirmed_on_changed +# Expected: R_CONFIRMED_ON_CHANGED (against predecessor valid/v01, N35 not reopened) +# Source: LabChronicle audit L8; Owner decisions of 6 Nov 2026 +# Valid on its own, but rep1 has the same members, units and channel lists as in v01 and confirmed_on moved from 2026-11-06 to 2026-11-07. confirmed_on is the date of the user's confirmation: it is kept byte-identical unless the confirmation was reopened and the user confirmed again. +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" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" +header_names = ["Phalloidin-488"] + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-07" + +[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/transitions/t06_member_added.toml b/tests/spec_fixtures/fixtures/transitions/t06_member_added.toml new file mode 100644 index 00000000..18035867 --- /dev/null +++ b/tests/spec_fixtures/fixtures/transitions/t06_member_added.toml @@ -0,0 +1,104 @@ +# Fixture t06_member_added +# Expected: no R_* code: accepted (against predecessor valid/v01) +# Source: Owner decision AQ1 +# Adding is not a change of entity. Against v01: a new condition DrugB joins the existing repeats rep1 and rep2 as a member, and a new folder CTRL/rep3 (new repeat rep3) joins the existing condition CTRL. Every member folder, position and alias of v01 is still there. The confirmation of rep1 and rep2 is reopened by the new member (grouping change, experiment-v1 10) and re-confirmed on 2026-11-07, so the changed `confirmed_on` is not R_CONFIRMED_ON_CHANGED. DrugB and rep3 are new IDs, listed in `used_ids`. Valid alone with W_REPEAT_MISSING_CONDITIONS (rep3). +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" + +[conditions.DrugB] +source_folder = "DrugB" +display_name = "Drug B" +reference = "CTRL" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" +header_names = ["Phalloidin-488"] + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-07" + +[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.rep1.members.DrugB] +source_repeat = "rep1_b" +image_count = 9 + +[repeats.rep1.members.DrugB.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.rep2] +confirmed = true +confirmed_on = "2026-11-07" + +[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"] + +[repeats.rep2.members.DrugB] +source_repeat = "rep2_b" +image_count = 8 + +[repeats.rep2.members.DrugB.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.rep3] +confirmed = true +confirmed_on = "2026-11-07" + +[repeats.rep3.members.CTRL] +source_repeat = "rep3" +image_count = 10 + +[repeats.rep3.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[used_ids] +conditions = ["CTRL", "DrugA", "DrugB"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1", "rep2", "rep3"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/transitions/t07_member_removed.toml b/tests/spec_fixtures/fixtures/transitions/t07_member_removed.toml new file mode 100644 index 00000000..c67eb93d --- /dev/null +++ b/tests/spec_fixtures/fixtures/transitions/t07_member_removed.toml @@ -0,0 +1,63 @@ +# Fixture t07_member_removed +# Expected: R_CONFIRMED_ID_CHANGED (against predecessor valid/v01, without a recorded removal confirmation) +# Source: Owner decision AQ1 +# Removing is a change of entity. The member DrugA/rep2 of repeat rep2 is gone (rep2 reopened and re-confirmed on 2026-11-07). The removal is accepted only with the recorded user confirmation of experiment-v1 4.6 and 10; without it the candidate is refused. All IDs stay listed in `used_ids`. Valid alone with W_REPEAT_MISSING_CONDITIONS. +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" +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-07" + +[repeats.rep2.members.CTRL] +source_repeat = "rep2" +image_count = 10 + +[repeats.rep2.members.CTRL.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/transitions/t08_member_reassigned.toml b/tests/spec_fixtures/fixtures/transitions/t08_member_reassigned.toml new file mode 100644 index 00000000..e6fb7e1d --- /dev/null +++ b/tests/spec_fixtures/fixtures/transitions/t08_member_reassigned.toml @@ -0,0 +1,69 @@ +# Fixture t08_member_reassigned +# Expected: R_CONFIRMED_ID_CHANGED (against predecessor valid/v03, without a recorded confirmation) +# Source: Owner decision AQ1 +# Reassigning is a change of entity. DrugA/experiment_23 was a member of Day1 in v03 and is now the member of a new repeat Day3, so Day1 lost a member it held. Accepted only with the recorded user confirmation of experiment-v1 4.6. Day3 is a new ID listed in `used_ids`. Valid alone with W_REPEAT_MISSING_CONDITIONS. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" + +[conditions.DrugA] +source_folder = "DrugA" +reference = "CTRL" + +[channels.DNA] +display_name = "DAPI" + +[channels.ACTIN] +display_name = "F-actin" + +[repeats.Day1] +confirmed = true +confirmed_on = "2026-11-07" + +[repeats.Day1.members.CTRL] +source_repeat = "rep1" +image_count = 9 + +[repeats.Day1.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.Day2] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.Day2.members.CTRL] +source_repeat = "rep2" +image_count = 9 + +[repeats.Day2.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.Day2.members.DrugA] +source_repeat = "rep2_drug" +image_count = 8 + +[repeats.Day2.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.Day3] +confirmed = true +confirmed_on = "2026-11-07" + +[repeats.Day3.members.DrugA] +source_repeat = "experiment_23" +image_count = 7 + +[repeats.Day3.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["Day1", "Day2", "Day3"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/transitions/t09_case_correction_new_id.toml b/tests/spec_fixtures/fixtures/transitions/t09_case_correction_new_id.toml new file mode 100644 index 00000000..4a9984a7 --- /dev/null +++ b/tests/spec_fixtures/fixtures/transitions/t09_case_correction_new_id.toml @@ -0,0 +1,71 @@ +# Fixture t09_case_correction_new_id +# Expected: R_CONFIRMED_ID_CHANGED (against predecessor valid/v01, accepted only with a recorded re-confirmation) +# Source: Owner decision AQ2 +# How a case-only mistake is corrected. v01's channel ACTIN was meant to be Actin; the corrected spelling cannot be used (it collides with the retired ACTIN, see t02 and i95), so the channel gets the new, non-colliding ID ActinStain at the same positions with the same header evidence, and `display_name` "Actin" carries the appearance. ACTIN stays listed in `used_ids`. The set is exactly {R_CONFIRMED_ID_CHANGED}: no R_USED_ID_DROPPED, no R_USED_ID_REUSED, no E_* code. +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" +header_names = ["DAPI"] + +[channels.ActinStain] +display_name = "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", "ActinStain"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 11 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ActinStain"] + +[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", "ActinStain"] + +[repeats.rep2.members.DrugA] +source_repeat = "rep2" +image_count = 10 + +[repeats.rep2.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ActinStain"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "ActinStain", "DNA"] +repeats = ["rep1", "rep2"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/transitions/t10_condition_id_changed_same_folder.toml b/tests/spec_fixtures/fixtures/transitions/t10_condition_id_changed_same_folder.toml new file mode 100644 index 00000000..32db910a --- /dev/null +++ b/tests/spec_fixtures/fixtures/transitions/t10_condition_id_changed_same_folder.toml @@ -0,0 +1,71 @@ +# Fixture t10_condition_id_changed_same_folder +# Expected: R_CONFIRMED_ID_CHANGED (against predecessor valid/v33, without a recorded re-confirmation) +# Source: Owner decision of 6 Nov 2026 (fourth round) +# The folder `control` was confirmed as condition `ctrl` in v33 and now carries the ID `control_cells`. A confirmed ID never changes in place, also when the folder is the same (experiment-v1 4.6, 4.6a). `ctrl` stays listed in used_ids, so only the changed ID is refused. Valid alone. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.control_cells] +source_folder = "control" +display_name = "control" + +[conditions.DrugA] +source_folder = "DrugA" +display_name = "Drug A" +reference = "control_cells" +target = "SRC" +treatment_key = "saracatinib" + +[channels.DNA] +display_name = "DAPI" +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.control_cells] +source_repeat = "rep1" +image_count = 12 + +[repeats.rep1.members.control_cells.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.control_cells] +source_repeat = "rep2" +image_count = 10 + +[repeats.rep2.members.control_cells.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 = ["DrugA", "control_cells", "ctrl"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1", "rep2"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/transitions/t11_technical_repeat_id_changed_same_folder.toml b/tests/spec_fixtures/fixtures/transitions/t11_technical_repeat_id_changed_same_folder.toml new file mode 100644 index 00000000..7135b1f7 --- /dev/null +++ b/tests/spec_fixtures/fixtures/transitions/t11_technical_repeat_id_changed_same_folder.toml @@ -0,0 +1,81 @@ +# Fixture t11_technical_repeat_id_changed_same_folder +# Expected: R_CONFIRMED_ID_CHANGED (against predecessor valid/v34, without a recorded re-confirmation) +# Source: Owner decision of 6 Nov 2026 (fourth round) +# 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. +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" +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.acqA] +source_folder = "files" +display_name = "files" +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.CTRL.technical_repeats.acq2] +source_folder = "overrides" +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 11 + +[repeats.rep1.members.DrugA.technical_repeats.acqA] +source_folder = "files" +display_name = "files" +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.DrugA.technical_repeats.acq2] +source_folder = "overrides" +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 = ["acq1", "acq2", "acqA", "single"] diff --git a/tests/spec_fixtures/fixtures/transitions/t12_condition_folder_reassigned.toml b/tests/spec_fixtures/fixtures/transitions/t12_condition_folder_reassigned.toml new file mode 100644 index 00000000..0a71e038 --- /dev/null +++ b/tests/spec_fixtures/fixtures/transitions/t12_condition_folder_reassigned.toml @@ -0,0 +1,71 @@ +# Fixture t12_condition_folder_reassigned +# Expected: R_CONFIRMED_ID_CHANGED (against predecessor valid/v33, without a recorded confirmation) +# Source: Owner decision of 6 Nov 2026 (fourth round) +# 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. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.ctrl] +source_folder = "ctl" +display_name = "control" + +[conditions.DrugA] +source_folder = "DrugA" +display_name = "Drug A" +reference = "ctrl" +target = "SRC" +treatment_key = "saracatinib" + +[channels.DNA] +display_name = "DAPI" +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 = ["DrugA", "ctrl"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1", "rep2"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/valid/v01_single_control.toml b/tests/spec_fixtures/fixtures/valid/v01_single_control.toml new file mode 100644 index 00000000..5e359613 --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v01_single_control.toml @@ -0,0 +1,71 @@ +# Fixture v01_single_control +# Expected: PASS +# Source: RFC case 1 +# One control; trivial pairing, so canonical repeat IDs default to the folder names. +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" +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/v02_multiple_controls.toml b/tests/spec_fixtures/fixtures/valid/v02_multiple_controls.toml new file mode 100644 index 00000000..cac26e7c --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v02_multiple_controls.toml @@ -0,0 +1,92 @@ +# Fixture v02_multiple_controls +# Expected: PASS +# Source: RFC case 2 +# Two controls (DMSO, WT), each referenced by two conditions. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.DMSO] +source_folder = "DMSO" + +[conditions.DrugA] +source_folder = "DrugA" +reference = "DMSO" + +[conditions.DrugB] +source_folder = "DrugB" +reference = "DMSO" + +[conditions.WT] +source_folder = "WT" + +[conditions.KO1] +source_folder = "KO1" +reference = "WT" + +[conditions.KO2] +source_folder = "KO2" +reference = "WT" + +[channels.DNA] +display_name = "DAPI" + +[channels.ACTIN] +display_name = "F-actin" + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep1.members.DMSO] +source_repeat = "rep1" +image_count = 8 + +[repeats.rep1.members.DMSO.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 8 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.DrugB] +source_repeat = "rep1" +image_count = 8 + +[repeats.rep1.members.DrugB.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.KO1] +source_repeat = "rep1" +image_count = 8 + +[repeats.rep1.members.KO1.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.KO2] +source_repeat = "rep1" +image_count = 8 + +[repeats.rep1.members.KO2.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.WT] +source_repeat = "rep1" +image_count = 8 + +[repeats.rep1.members.WT.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[used_ids] +conditions = ["DMSO", "DrugA", "DrugB", "KO1", "KO2", "WT"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/valid/v03_paired_different_folder_names.toml b/tests/spec_fixtures/fixtures/valid/v03_paired_different_folder_names.toml new file mode 100644 index 00000000..988fb46e --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v03_paired_different_folder_names.toml @@ -0,0 +1,65 @@ +# Fixture v03_paired_different_folder_names +# Expected: PASS +# Source: RFC case 3; adversarial 8 +# Source folders rep1 (CTRL) and experiment_23 (DrugA) paired by the user into canonical repeat Day1; rep2/rep2_drug into Day2. MorphoCoverage must see Day1 and Day2 as the biological repeats. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" + +[conditions.DrugA] +source_folder = "DrugA" +reference = "CTRL" + +[channels.DNA] +display_name = "DAPI" + +[channels.ACTIN] +display_name = "F-actin" + +[repeats.Day1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.Day1.members.CTRL] +source_repeat = "rep1" +image_count = 9 + +[repeats.Day1.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.Day1.members.DrugA] +source_repeat = "experiment_23" +image_count = 7 + +[repeats.Day1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.Day2] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.Day2.members.CTRL] +source_repeat = "rep2" +image_count = 9 + +[repeats.Day2.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.Day2.members.DrugA] +source_repeat = "rep2_drug" +image_count = 8 + +[repeats.Day2.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["Day1", "Day2"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/valid/v04_swapped_channel_order.toml b/tests/spec_fixtures/fixtures/valid/v04_swapped_channel_order.toml new file mode 100644 index 00000000..8599e9b0 --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v04_swapped_channel_order.toml @@ -0,0 +1,45 @@ +# Fixture v04_swapped_channel_order +# Expected: PASS +# Source: RFC case 4; adversarial 3 +# Same channels, opposite source order between conditions (CTRL C0=DNA, DrugA C0=ACTIN). Valid schema; heterogeneous, so Stage 1-2 runtime refuses (R_HETEROGENEOUS_LAYOUT_NOT_ENABLED). From Stage 3, ACTIN and DNA are never mixed by C index. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" + +[conditions.DrugA] +source_folder = "DrugA" +reference = "CTRL" + +[channels.DNA] +display_name = "DAPI" + +[channels.ACTIN] +display_name = "F-actin" + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep1.members.CTRL] +source_repeat = "rep1" +image_count = 6 + +[repeats.rep1.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 6 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["ACTIN", "DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/valid/v05_variable_channel_counts.toml b/tests/spec_fixtures/fixtures/valid/v05_variable_channel_counts.toml new file mode 100644 index 00000000..8a90d03f --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v05_variable_channel_counts.toml @@ -0,0 +1,48 @@ +# Fixture v05_variable_channel_counts +# Expected: PASS +# Source: RFC case 5 +# Extra channel PHASE in DrugA/rep1 only; CTRL lacks it (missing elsewhere). Heterogeneous. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" + +[conditions.DrugA] +source_folder = "DrugA" +reference = "CTRL" + +[channels.DNA] +display_name = "DAPI" + +[channels.ACTIN] +display_name = "F-actin" + +[channels.PHASE] +display_name = "Phase contrast" + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep1.members.CTRL] +source_repeat = "rep1" +image_count = 6 + +[repeats.rep1.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 6 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN", "PHASE"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA", "PHASE"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/valid/v06_technical_repeats_different_layouts.toml b/tests/spec_fixtures/fixtures/valid/v06_technical_repeats_different_layouts.toml new file mode 100644 index 00000000..0c9676d8 --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v06_technical_repeats_different_layouts.toml @@ -0,0 +1,75 @@ +# Fixture v06_technical_repeats_different_layouts +# Expected: PASS +# Source: RFC case 6; adversarial 1 +# One biological repeat (R002) whose CTRL member has two technical repeats with different counts and orders. It stays ONE biological repeat (N = 2 repeats in total, not 3). Heterogeneous. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" + +[conditions.DrugA] +source_folder = "DrugA" +reference = "CTRL" + +[channels.DNA] +display_name = "DAPI" + +[channels.ACTIN] +display_name = "F-actin" + +[channels.PAXILLIN] +display_name = "Paxillin" + +[channels.PHASE] +display_name = "Phase contrast" + +[repeats.R001] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.R001.members.CTRL] +source_repeat = "rep1" +image_count = 5 + +[repeats.R001.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN", "PAXILLIN"] + +[repeats.R001.members.DrugA] +source_repeat = "experiment_23" +image_count = 5 + +[repeats.R001.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["ACTIN", "DNA", "PAXILLIN"] + +[repeats.R002] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.R002.members.CTRL] +source_repeat = "rep2" +image_count = 9 + +[repeats.R002.members.CTRL.technical_repeats.acquisition_1] +source_folder = "acquisition_1" +channels = ["DNA", "ACTIN", "PAXILLIN", "PHASE"] + +[repeats.R002.members.CTRL.technical_repeats.acquisition_2] +source_folder = "acquisition_2" +channels = ["ACTIN", "DNA", "PAXILLIN"] + +[repeats.R002.members.DrugA] +source_repeat = "rep2_drug" +image_count = 5 + +[repeats.R002.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN", "PAXILLIN"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA", "PAXILLIN", "PHASE"] +repeats = ["R001", "R002"] +technical_repeats = ["acquisition_1", "acquisition_2", "single"] diff --git a/tests/spec_fixtures/fixtures/valid/v07_instance_masks.toml b/tests/spec_fixtures/fixtures/valid/v07_instance_masks.toml new file mode 100644 index 00000000..95be34f6 --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v07_instance_masks.toml @@ -0,0 +1,44 @@ +# Fixture v07_instance_masks +# Expected: PASS +# Source: RFC case 8 +# Two instance masks with source-channel provenance; canonical mask IDs differ from discovered source types. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" + +[channels.DNA] +display_name = "DAPI" + +[channels.ACTIN] +display_name = "F-actin" + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep1.members.CTRL] +source_repeat = "rep1" +image_count = 4 + +[repeats.rep1.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[masks.Cells] +source_type = "cells" +representation = "instances" +source_channels = ["ACTIN", "DNA"] +display_name = "Whole cells" + +[used_ids] +conditions = ["CTRL"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/valid/v08_binary_masks.toml b/tests/spec_fixtures/fixtures/valid/v08_binary_masks.toml new file mode 100644 index 00000000..d0a6af82 --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v08_binary_masks.toml @@ -0,0 +1,42 @@ +# Fixture v08_binary_masks +# Expected: PASS +# Source: RFC case 9; adversarial 6 +# A binary region mask beside an instance mask. Valid; an analysis request using TumorRegion as an object set must refuse (A_MASK_NOT_INSTANCES). +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" + +[channels.DNA] +display_name = "DAPI" + +[channels.ACTIN] +display_name = "F-actin" + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep1.members.CTRL] +source_repeat = "rep1" +image_count = 4 + +[repeats.rep1.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[masks.TumorRegion] +source_type = "tumor_region" +representation = "binary" + +[used_ids] +conditions = ["CTRL"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/valid/v09_partial_masks.toml b/tests/spec_fixtures/fixtures/valid/v09_partial_masks.toml new file mode 100644 index 00000000..86375f3f --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v09_partial_masks.toml @@ -0,0 +1,50 @@ +# Fixture v09_partial_masks +# Expected: PASS +# Source: RFC case 10 +# Masks declared while segmentation is in progress (e.g. 0/1000 or 300/1000 FOVs). Completeness lives in the run manifest, so the file is valid at every stage; scope checks belong to the analysis layer (A_SCOPE_INCOMPLETE). +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" + +[conditions.DrugA] +source_folder = "DrugA" +reference = "CTRL" + +[channels.DNA] +display_name = "DAPI" + +[channels.ACTIN] +display_name = "F-actin" + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep1.members.CTRL] +source_repeat = "rep1" +image_count = 500 + +[repeats.rep1.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 500 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/valid/v10_reference_chain.toml b/tests/spec_fixtures/fixtures/valid/v10_reference_chain.toml new file mode 100644 index 00000000..7daae9d2 --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v10_reference_chain.toml @@ -0,0 +1,96 @@ +# Fixture v10_reference_chain +# Expected: PASS +# Source: RFC case 11 +# Reference chains VEC_SiCol6a1 -> VEC_SiNC -> VEC and THBS5_SiCol6a1 -> THBS5_SiNC -> THBS5; shared treatment_key across backgrounds. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.VEC] +source_folder = "VEC" + +[conditions.VEC_SiNC] +source_folder = "VEC_SiNC" +reference = "VEC" + +[conditions.VEC_SiCol6a1] +source_folder = "VEC_SiCol6a1" +reference = "VEC_SiNC" +target = "COL6A1" +treatment_key = "siCol6a1" + +[conditions.THBS5] +source_folder = "THBS5" + +[conditions.THBS5_SiNC] +source_folder = "THBS5_SiNC" +reference = "THBS5" + +[conditions.THBS5_SiCol6a1] +source_folder = "THBS5_SiCol6a1" +reference = "THBS5_SiNC" +target = "COL6A1" +treatment_key = "siCol6a1" + +[channels.DNA] +display_name = "DAPI" + +[channels.ACTIN] +display_name = "F-actin" + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep1.members.THBS5] +source_repeat = "rep1" +image_count = 6 + +[repeats.rep1.members.THBS5.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.THBS5_SiCol6a1] +source_repeat = "rep1" +image_count = 6 + +[repeats.rep1.members.THBS5_SiCol6a1.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.THBS5_SiNC] +source_repeat = "rep1" +image_count = 6 + +[repeats.rep1.members.THBS5_SiNC.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.VEC] +source_repeat = "rep1" +image_count = 6 + +[repeats.rep1.members.VEC.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.VEC_SiCol6a1] +source_repeat = "rep1" +image_count = 6 + +[repeats.rep1.members.VEC_SiCol6a1.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.VEC_SiNC] +source_repeat = "rep1" +image_count = 6 + +[repeats.rep1.members.VEC_SiNC.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[used_ids] +conditions = ["THBS5", "THBS5_SiCol6a1", "THBS5_SiNC", "VEC", "VEC_SiCol6a1", "VEC_SiNC"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/valid/v11_header_aliases.toml b/tests/spec_fixtures/fixtures/valid/v11_header_aliases.toml new file mode 100644 index 00000000..122f3ffc --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v11_header_aliases.toml @@ -0,0 +1,38 @@ +# Fixture v11_header_aliases +# Expected: PASS +# Source: Adversarial 5 +# Canonical DNA confirmed with header evidence DAPI and Hoechst 33342; canonical ACTIN with an exact-match header name that still had to be confirmed. A header name outside these lists is R_HEADER_CONTRADICTION. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" + +[channels.DNA] +display_name = "Nuclei stain" +header_names = ["DAPI", "Hoechst 33342"] + +[channels.ACTIN] +header_names = ["ACTIN"] + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep1.members.CTRL] +source_repeat = "rep1" +image_count = 4 + +[repeats.rep1.members.CTRL.technical_repeats.acquisition_1] +source_folder = "acquisition_1" +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.CTRL.technical_repeats.acquisition_2] +source_folder = "acquisition_2" +channels = ["DNA", "ACTIN"] + +[used_ids] +conditions = ["CTRL"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["acquisition_1", "acquisition_2"] diff --git a/tests/spec_fixtures/fixtures/valid/v12_unnamed_headers.toml b/tests/spec_fixtures/fixtures/valid/v12_unnamed_headers.toml new file mode 100644 index 00000000..dfb20965 --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v12_unnamed_headers.toml @@ -0,0 +1,32 @@ +# Fixture v12_unnamed_headers +# Expected: PASS +# Source: Extra: aliases +# No header evidence at all (ImageJ TIFFs carry no channel names); the user's confirmation is the only guard. Undetectable physical swaps are a documented limit. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" + +[channels.DNA] + +[channels.ACTIN] +header_names = [] + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep1.members.CTRL] +source_repeat = "rep1" +image_count = 4 + +[repeats.rep1.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[used_ids] +conditions = ["CTRL"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/valid/v13_homogeneous_legacy.toml b/tests/spec_fixtures/fixtures/valid/v13_homogeneous_legacy.toml new file mode 100644 index 00000000..2fe50533 --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v13_homogeneous_legacy.toml @@ -0,0 +1,113 @@ +# Fixture v13_homogeneous_legacy +# Expected: PASS +# Source: Adversarial 4 +# A legacy homogeneous dataset: folder-name repeat IDs, one control, same layout everywhere (IDs proposed equal to the folder labels). The derived experiment_design.csv, which carries the `source_folder` labels, must be byte-identical to today's; numerical outputs must keep parity. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" + +[conditions.DrugA] +source_folder = "DrugA" +reference = "CTRL" + +[conditions.DrugB] +source_folder = "DrugB" +reference = "CTRL" + +[channels.DNA] +display_name = "DAPI" + +[channels.ACTIN] +display_name = "F-actin" + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-10-30" + +[repeats.rep1.members.CTRL] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.DrugB] +source_repeat = "rep1" +image_count = 10 + +[repeats.rep1.members.DrugB.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.rep2] +confirmed = true +confirmed_on = "2026-10-30" + +[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"] + +[repeats.rep2.members.DrugB] +source_repeat = "rep2" +image_count = 10 + +[repeats.rep2.members.DrugB.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.rep3] +confirmed = true +confirmed_on = "2026-10-30" + +[repeats.rep3.members.CTRL] +source_repeat = "rep3" +image_count = 10 + +[repeats.rep3.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.rep3.members.DrugA] +source_repeat = "rep3" +image_count = 10 + +[repeats.rep3.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.rep3.members.DrugB] +source_repeat = "rep3" +image_count = 10 + +[repeats.rep3.members.DrugB.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[used_ids] +conditions = ["CTRL", "DrugA", "DrugB"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1", "rep2", "rep3"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/valid/v14_no_repeat_folders.toml b/tests/spec_fixtures/fixtures/valid/v14_no_repeat_folders.toml new file mode 100644 index 00000000..6d6fa282 --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v14_no_repeat_folders.toml @@ -0,0 +1,45 @@ +# Fixture v14_no_repeat_folders +# Expected: PASS +# Source: Extra: single convention +# No biological-repeat or technical-repeat folders: the source label is `single` (N31, one group for the whole experiment). +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" + +[conditions.DrugA] +source_folder = "DrugA" +reference = "CTRL" + +[channels.DNA] +display_name = "DAPI" + +[channels.ACTIN] +display_name = "F-actin" + +[repeats.single] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.single.members.CTRL] +source_repeat = "single" +image_count = 20 + +[repeats.single.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.single.members.DrugA] +source_repeat = "single" +image_count = 18 + +[repeats.single.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["single"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/valid/v15_toml_keys_are_text.toml b/tests/spec_fixtures/fixtures/valid/v15_toml_keys_are_text.toml new file mode 100644 index 00000000..00e945c3 --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v15_toml_keys_are_text.toml @@ -0,0 +1,45 @@ +# Fixture v15_toml_keys_are_text +# Expected: PASS +# Source: Extra: implicit-conversion traps (valid side) +# Folder labels that YAML would retype (2026-01-01, 012, NO, 1.0, off) stay exact text in TOML keys and strings. The quoted key "1.0" is needed: a bare 1.0 would be a dotted key. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.NO] +source_folder = "NO" + +[conditions."1.0"] +source_folder = "1.0" +reference = "NO" + +[channels.DNA] +display_name = "DAPI" + +[channels.ACTIN] +display_name = "F-actin" + +[repeats.2026-01-01] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.2026-01-01.members.NO] +source_repeat = "2026-01-01" +image_count = 3 + +[repeats.2026-01-01.members.NO.technical_repeats.012] +source_folder = "012" +channels = ["DNA", "ACTIN"] + +[repeats.2026-01-01.members."1.0"] +source_repeat = "2026-01-01" +image_count = 3 + +[repeats.2026-01-01.members."1.0".technical_repeats.off] +source_folder = "off" +channels = ["DNA", "ACTIN"] + +[used_ids] +conditions = ["1.0", "NO"] +channels = ["ACTIN", "DNA"] +repeats = ["2026-01-01"] +technical_repeats = ["012", "off"] diff --git a/tests/spec_fixtures/fixtures/valid/v16_display_name_change.toml b/tests/spec_fixtures/fixtures/valid/v16_display_name_change.toml new file mode 100644 index 00000000..9c52701b --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v16_display_name_change.toml @@ -0,0 +1,73 @@ +# Fixture v16_display_name_change +# Expected: PASS +# Source: Owner refinement (6 Nov 2026): immutable IDs +# v01 with every display_name changed or added (conditions, channels, repeats). All IDs, references, units and members are identical, so identity is identical: the conditions, channels, repeats and masks section digests (display_name omitted, experiment-v1 9.3) equal v01's and nothing may be invalidated. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" +display_name = "Vehicle control" + +[conditions.DrugA] +source_folder = "DrugA" +display_name = "Saracatinib 1 uM" +reference = "CTRL" +target = "SRC" +treatment_key = "saracatinib" + +[channels.DNA] +display_name = "Nuclei (Hoechst)" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "Actin (phalloidin)" +header_names = ["Phalloidin-488"] + +[repeats.rep1] +display_name = "Day 1" +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] +display_name = "Day 2" +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/v17_partial_channel_presence.toml b/tests/spec_fixtures/fixtures/valid/v17_partial_channel_presence.toml new file mode 100644 index 00000000..08d3f289 --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v17_partial_channel_presence.toml @@ -0,0 +1,51 @@ +# Fixture v17_partial_channel_presence +# Expected: PASS +# Source: Adversarial 9; addendum §3 +# PHASE exists only in CTRL/R002/acquisition_1. For a PHASE request R002 is partially represented and R001 unavailable: the analysis layer must refuse (A_SCOPE_INCOMPLETE) or record a user-selected subset, never drop or re-weight silently. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" + +[channels.DNA] +display_name = "DAPI" + +[channels.ACTIN] +display_name = "F-actin" + +[channels.PHASE] + +[repeats.R001] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.R001.members.CTRL] +source_repeat = "rep1" +image_count = 5 + +[repeats.R001.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.R002] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.R002.members.CTRL] +source_repeat = "rep2" +image_count = 8 + +[repeats.R002.members.CTRL.technical_repeats.acquisition_1] +source_folder = "acquisition_1" +channels = ["DNA", "ACTIN", "PHASE"] + +[repeats.R002.members.CTRL.technical_repeats.acquisition_2] +source_folder = "acquisition_2" +channels = ["DNA", "ACTIN"] + +[used_ids] +conditions = ["CTRL"] +channels = ["ACTIN", "DNA", "PHASE"] +repeats = ["R001", "R002"] +technical_repeats = ["acquisition_1", "acquisition_2", "single"] diff --git a/tests/spec_fixtures/fixtures/valid/v18_inline_and_dotted_forms.toml b/tests/spec_fixtures/fixtures/valid/v18_inline_and_dotted_forms.toml new file mode 100644 index 00000000..93c283b3 --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v18_inline_and_dotted_forms.toml @@ -0,0 +1,37 @@ +# Fixture v18_inline_and_dotted_forms +# Expected: PASS +# Source: Extra: round trip +# Same model as v01 written with inline tables, dotted keys and quoted keys; parse(v18) must equal parse(v01). +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", header_names = ["DAPI"] } +ACTIN = { display_name = "F-actin", header_names = ["Phalloidin-488"] } + +[repeats.rep2] +confirmed = true +confirmed_on = "2026-11-06" +members.CTRL = { source_repeat = "rep2", image_count = 10, technical_repeats = { single = { source_folder = "single", channels = ["DNA", "ACTIN"] } } } +members.DrugA = { source_repeat = "rep2", image_count = 10, technical_repeats.single.source_folder = "single", technical_repeats.single.channels = ["DNA", "ACTIN"] } + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-06" +members.DrugA.source_repeat = "rep1" +members.DrugA.image_count = 11 +members.DrugA.technical_repeats.single.source_folder = "single" +members.DrugA.technical_repeats.single.channels = ["DNA", "ACTIN"] +members.CTRL.source_repeat = "rep1" +members.CTRL.image_count = 12 +members.CTRL.technical_repeats.single.source_folder = "single" +members.CTRL.technical_repeats.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/v19_warnings_unpaired.toml b/tests/spec_fixtures/fixtures/valid/v19_warnings_unpaired.toml new file mode 100644 index 00000000..216d0605 --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v19_warnings_unpaired.toml @@ -0,0 +1,49 @@ +# Fixture v19_warnings_unpaired +# Expected: PASS; warnings W_REFERENCE_UNPAIRED, W_REPEAT_MISSING_CONDITIONS +# Source: Extra: reference DAG semantics +# Valid with warnings: CTRL only in rep1, DrugA only in rep2, so the repeats miss conditions (W_REPEAT_MISSING_CONDITIONS) and DrugA cannot be paired with its reference (W_REFERENCE_UNPAIRED). +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" + +[conditions.DrugA] +source_folder = "DrugA" +reference = "CTRL" + +[channels.DNA] +display_name = "DAPI" + +[channels.ACTIN] +display_name = "F-actin" + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep1.members.CTRL] +source_repeat = "rep1" +image_count = 5 + +[repeats.rep1.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.rep2] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep2.members.DrugA] +source_repeat = "rep2" +image_count = 5 + +[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/v20_unicode_and_spaces.toml b/tests/spec_fixtures/fixtures/valid/v20_unicode_and_spaces.toml new file mode 100644 index 00000000..7bbacc7e --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v20_unicode_and_spaces.toml @@ -0,0 +1,46 @@ +# Fixture v20_unicode_and_spaces +# Expected: PASS +# Source: Extra: identities +# Condition folder labels with spaces and non-ASCII text are valid labels (quoted keys); channel and mask IDs stay tokens. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions."Wild type"] +source_folder = "Wild type" +display_name = "Wild type (WT)" + +[conditions."Knock-out β1"] +source_folder = "Knock-out β1" +reference = "Wild type" + +[channels.DNA] +display_name = "DAPI" + +[channels.ACTIN] +display_name = "F-actin" + +[repeats."Réplica_1"] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats."Réplica_1".members."Wild type"] +source_repeat = "Réplica_1" +image_count = 4 + +[repeats."Réplica_1".members."Wild type".technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats."Réplica_1".members."Knock-out β1"] +source_repeat = "Réplica_1" +image_count = 4 + +[repeats."Réplica_1".members."Knock-out β1".technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[used_ids] +conditions = ["Knock-out \u03b21", "Wild type"] +channels = ["ACTIN", "DNA"] +repeats = ["R\u00e9plica_1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/valid/v21_metadata_only_edit.toml b/tests/spec_fixtures/fixtures/valid/v21_metadata_only_edit.toml new file mode 100644 index 00000000..81f53642 --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v21_metadata_only_edit.toml @@ -0,0 +1,70 @@ +# Fixture v21_metadata_only_edit +# Expected: PASS +# Source: Adversarial 10 +# v01 with only descriptive condition fields edited (display name, target removed, treatment_key changed). All four section digests equal v01's (display_name, target and treatment_key are omitted, experiment-v1 9.3), so no pixel-derived product may be invalidated. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" +display_name = "Vehicle (DMSO 0.1%)" + +[conditions.DrugA] +source_folder = "DrugA" +display_name = "Drug A" +reference = "CTRL" +treatment_key = "AZD0530" + +[channels.DNA] +display_name = "DAPI" +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/v22_unused_channel_warning.toml b/tests/spec_fixtures/fixtures/valid/v22_unused_channel_warning.toml new file mode 100644 index 00000000..0770728c --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v22_unused_channel_warning.toml @@ -0,0 +1,55 @@ +# Fixture v22_unused_channel_warning +# Expected: PASS + W_UNUSED_CHANNEL +# Source: Owner decision 3 (6 Nov 2026) +# PHASE is declared but no unit acquires it. The file is valid; the reader reports the warning W_UNUSED_CHANNEL (a declared channel can be pre-confirmed or a unit excluded). It is never an error. +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" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[channels.PHASE] + +[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 = 10 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" +source_channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA", "PHASE"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/valid/v23_nfc_identity.toml b/tests/spec_fixtures/fixtures/valid/v23_nfc_identity.toml new file mode 100644 index 00000000..0a6d7ad3 --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v23_nfc_identity.toml @@ -0,0 +1,71 @@ +# Fixture v23_nfc_identity +# Expected: PASS +# Source: Owner decisions of 6 Nov 2026 (Q3) +# A condition key written composed (U+00E9, NFC) and referenced composed. A writer emits only this form and a reader accepts only this form: any decomposed spelling of an identity is E_ID_NOT_NFC (i78, i83-i85), never normalized. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions."Caf\u00e9"] +source_folder = "Caf\u00e9" +display_name = "Control" + +[conditions.DrugA] +source_folder = "DrugA" +display_name = "Drug A" +reference = "Caf\u00e9" +target = "SRC" +treatment_key = "saracatinib" + +[channels.DNA] +display_name = "DAPI" +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."Caf\u00e9"] +source_repeat = "rep1" +image_count = 12 + +[repeats.rep1.members."Caf\u00e9".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."Caf\u00e9"] +source_repeat = "rep2" +image_count = 10 + +[repeats.rep2.members."Caf\u00e9".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 = ["Caf\u00e9", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1", "rep2"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/valid/v24_display_name_as_typed.toml b/tests/spec_fixtures/fixtures/valid/v24_display_name_as_typed.toml new file mode 100644 index 00000000..feeaff58 --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v24_display_name_as_typed.toml @@ -0,0 +1,73 @@ +# Fixture v24_display_name_as_typed +# Expected: PASS +# Source: Owner decisions of 6 Nov 2026 (Q2) +# Display names are kept as typed: one decomposed (U+0301) and one lower-case, neither normalized. The channel ACTIN and the condition DrugA both display as "Drug A": uniqueness is per kind, so this is valid. Collisions are decided after NFC + case folding within one kind (i53, i76, i77, i86-i88). +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" +display_name = "Cafe\u0301 control" + +[conditions.DrugA] +source_folder = "DrugA" +display_name = "Drug A" +reference = "CTRL" +target = "SRC" +treatment_key = "saracatinib" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "Drug A" +header_names = ["Phalloidin-488"] + +[repeats.rep1] +display_name = "Day 1" +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] +display_name = "day 2" +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/v25_removed_condition_id_retained.toml b/tests/spec_fixtures/fixtures/valid/v25_removed_condition_id_retained.toml new file mode 100644 index 00000000..fc47bf73 --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v25_removed_condition_id_retained.toml @@ -0,0 +1,48 @@ +# Fixture v25_removed_condition_id_retained +# Expected: PASS +# Source: Owner decisions of 6 Nov 2026 (Q1) +# v01 after the user confirmed the removal of every DrugA member and the condition. DrugA is no longer declared but stays in used_ids.conditions: removing an entity keeps its ID listed. This is the published predecessor of t03 and t04. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" +display_name = "Control" + +[channels.DNA] +display_name = "DAPI" +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.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"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1", "rep2"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/valid/v26_ids_kept_as_written.toml b/tests/spec_fixtures/fixtures/valid/v26_ids_kept_as_written.toml new file mode 100644 index 00000000..b20d76dc --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v26_ids_kept_as_written.toml @@ -0,0 +1,71 @@ +# Fixture v26_ids_kept_as_written +# Expected: PASS +# Source: Owner decisions of 6 Nov 2026 (A2); LabChronicle audit B1, B2, B11 +# IDs are opaque text with no width, prefix, case or number rule: cl001 (lower case), CL1000 (four digits after a three-digit width), R999 and R1000 are all valid and a reader or writer MUST keep each exactly as written, never padding, re-casing or renumbering it. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.cl001] +source_folder = "cl001" +display_name = "Control" + +[conditions.CL1000] +source_folder = "CL1000" +display_name = "Drug A" +reference = "cl001" +target = "SRC" +treatment_key = "saracatinib" + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" +header_names = ["Phalloidin-488"] + +[repeats.R999] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.R999.members.cl001] +source_repeat = "rep1" +image_count = 12 + +[repeats.R999.members.cl001.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.R999.members.CL1000] +source_repeat = "rep1" +image_count = 11 + +[repeats.R999.members.CL1000.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.R1000] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.R1000.members.cl001] +source_repeat = "rep2" +image_count = 10 + +[repeats.R1000.members.cl001.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.R1000.members.CL1000] +source_repeat = "rep2" +image_count = 10 + +[repeats.R1000.members.CL1000.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[used_ids] +conditions = ["CL1000", "cl001"] +channels = ["ACTIN", "DNA"] +repeats = ["R1000", "R999"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/valid/v27_descriptive_keys_collide.toml b/tests/spec_fixtures/fixtures/valid/v27_descriptive_keys_collide.toml new file mode 100644 index 00000000..3ba59e38 --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v27_descriptive_keys_collide.toml @@ -0,0 +1,108 @@ +# Fixture v27_descriptive_keys_collide +# Expected: PASS +# Source: Owner decisions of 6 Nov 2026 (Q4) +# target and treatment_key are descriptive only. DrugA and DrugC carry identical values, DrugB the same values in another case: no collision, no warning, no grouping. A consumer MUST NOT group, join, pair or key a cache on them. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" + +[conditions.DrugA] +source_folder = "DrugA" +reference = "CTRL" +target = "SRC" +treatment_key = "AZD0530" + +[conditions.DrugB] +source_folder = "DrugB" +reference = "CTRL" +target = "src" +treatment_key = "azd0530" + +[conditions.DrugC] +source_folder = "DrugC" +reference = "CTRL" +target = "SRC" +treatment_key = "AZD0530" + +[channels.DNA] +header_names = ["DAPI"] + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep1.members.CTRL] +source_repeat = "rep1" +image_count = 5 + +[repeats.rep1.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 5 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA"] + +[repeats.rep1.members.DrugB] +source_repeat = "rep1" +image_count = 5 + +[repeats.rep1.members.DrugB.technical_repeats.single] +source_folder = "single" +channels = ["DNA"] + +[repeats.rep1.members.DrugC] +source_repeat = "rep1" +image_count = 5 + +[repeats.rep1.members.DrugC.technical_repeats.single] +source_folder = "single" +channels = ["DNA"] + +[repeats.rep2] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep2.members.CTRL] +source_repeat = "rep2" +image_count = 5 + +[repeats.rep2.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA"] + +[repeats.rep2.members.DrugA] +source_repeat = "rep2" +image_count = 5 + +[repeats.rep2.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA"] + +[repeats.rep2.members.DrugB] +source_repeat = "rep2" +image_count = 5 + +[repeats.rep2.members.DrugB.technical_repeats.single] +source_folder = "single" +channels = ["DNA"] + +[repeats.rep2.members.DrugC] +source_repeat = "rep2" +image_count = 5 + +[repeats.rep2.members.DrugC.technical_repeats.single] +source_folder = "single" +channels = ["DNA"] + +[used_ids] +conditions = ["CTRL", "DrugA", "DrugB", "DrugC"] +channels = ["DNA"] +repeats = ["rep1", "rep2"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/valid/v28_rgb_units.toml b/tests/spec_fixtures/fixtures/valid/v28_rgb_units.toml new file mode 100644 index 00000000..693a5a30 --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v28_rgb_units.toml @@ -0,0 +1,78 @@ +# Fixture v28_rgb_units +# Expected: PASS +# Source: Owner decision AQ6 (supersedes D8) +# An RGB experiment (e.g. H&E): every unit declares `image_type = "rgb"` with the fixed channels R, G, B in that order and no aliases. Valid and homogeneous. The scientific meaning is left to each brick: a brick that does not declare RGB support refuses a request on these units (A_RGB_NOT_SUPPORTED, P24); preprocessing compares the declaration with the image header and never with pixels (P13, P23). +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" + +[channels.R] +display_name = "Red" + +[channels.G] +display_name = "Green" + +[channels.B] +display_name = "Blue" + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep1.members.CTRL] +source_repeat = "rep1" +image_count = 8 + +[repeats.rep1.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["R", "G", "B"] +image_type = "rgb" + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 8 + +[repeats.rep1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["R", "G", "B"] +image_type = "rgb" + +[repeats.rep2] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep2.members.CTRL] +source_repeat = "rep2" +image_count = 8 + +[repeats.rep2.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["R", "G", "B"] +image_type = "rgb" + +[repeats.rep2.members.DrugA] +source_repeat = "rep2" +image_count = 8 + +[repeats.rep2.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["R", "G", "B"] +image_type = "rgb" + +[masks.Nuclei] +source_type = "nuclei" +representation = "instances" + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["B", "G", "R"] +repeats = ["rep1", "rep2"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/valid/v29_rgb_and_channels_mixed.toml b/tests/spec_fixtures/fixtures/valid/v29_rgb_and_channels_mixed.toml new file mode 100644 index 00000000..ae9195f1 --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v29_rgb_and_channels_mixed.toml @@ -0,0 +1,46 @@ +# Fixture v29_rgb_and_channels_mixed +# Expected: PASS +# Source: Owner decision AQ6 +# One condition with an RGB brightfield unit and a two-channel fluorescence unit of the same biological repeat. Valid schema; heterogeneous (different lists), so the Stage 1-2 runtime refuses it (R_HETEROGENEOUS_LAYOUT_NOT_ENABLED). Both units stay ONE biological repeat. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" +display_name = "Control" + +[channels.R] + +[channels.G] + +[channels.B] + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[channels.ACTIN] +display_name = "F-actin" + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep1.members.CTRL] +source_repeat = "rep1" +image_count = 6 + +[repeats.rep1.members.CTRL.technical_repeats.brightfield] +source_folder = "brightfield" +channels = ["R", "G", "B"] +image_type = "rgb" + +[repeats.rep1.members.CTRL.technical_repeats.fluorescence] +source_folder = "fluorescence" +channels = ["DNA", "ACTIN"] + +[used_ids] +conditions = ["CTRL"] +channels = ["ACTIN", "B", "DNA", "G", "R"] +repeats = ["rep1"] +technical_repeats = ["brightfield", "fluorescence"] diff --git a/tests/spec_fixtures/fixtures/valid/v30_same_source_label_different_repeats.toml b/tests/spec_fixtures/fixtures/valid/v30_same_source_label_different_repeats.toml new file mode 100644 index 00000000..3b77cc9d --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v30_same_source_label_different_repeats.toml @@ -0,0 +1,65 @@ +# Fixture v30_same_source_label_different_repeats +# Expected: PASS +# Source: Owner decision AQ5; S0.1 audit section 5 +# Source folder label rep1 is in canonical repeat Day1 for CTRL and in Day2 for DrugA (and rep2 the other way round). Valid: no repeat ID equals another repeat's source label. The FOV key keeps the source label in `biological_repeat` and stays unique through the condition; the added `repeat_group` is Day1 or Day2, and grouping and pairing use it (experiment-v1 3.4, P25). +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" + +[conditions.DrugA] +source_folder = "DrugA" +reference = "CTRL" + +[channels.DNA] +display_name = "DAPI" + +[channels.ACTIN] +display_name = "F-actin" + +[repeats.Day1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.Day1.members.CTRL] +source_repeat = "rep1" +image_count = 9 + +[repeats.Day1.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.Day1.members.DrugA] +source_repeat = "rep2" +image_count = 9 + +[repeats.Day1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.Day2] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.Day2.members.CTRL] +source_repeat = "rep2" +image_count = 9 + +[repeats.Day2.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.Day2.members.DrugA] +source_repeat = "rep1" +image_count = 9 + +[repeats.Day2.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[used_ids] +conditions = ["CTRL", "DrugA"] +channels = ["ACTIN", "DNA"] +repeats = ["Day1", "Day2"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/valid/v31_header_alias_whitespace_and_decomposed.toml b/tests/spec_fixtures/fixtures/valid/v31_header_alias_whitespace_and_decomposed.toml new file mode 100644 index 00000000..0bea1698 --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v31_header_alias_whitespace_and_decomposed.toml @@ -0,0 +1,33 @@ +# Fixture v31_header_alias_whitespace_and_decomposed +# Expected: PASS +# Source: Owner decision AQ7 +# Header evidence is compared exactly after NFC of both sides, with no trimming. DNA lists the aliases "DAPI", " DAPI" (leading space) and "DAPI " (trailing space): outer whitespace needs its own confirmed alias, each is a distinct entry. ACTIN lists a decomposed alias (written with a TOML escape and stored as typed), which matches the header name "Caf\u00e9 488" after NFC of both sides (P26). header_names are not identities and are not NFC-checked. +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" + +[channels.DNA] +header_names = ["DAPI", " DAPI", "DAPI "] + +[channels.ACTIN] +header_names = ["Cafe\u0301 488"] + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep1.members.CTRL] +source_repeat = "rep1" +image_count = 4 + +[repeats.rep1.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[used_ids] +conditions = ["CTRL"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/valid/v32_reserved_name_near_misses.toml b/tests/spec_fixtures/fixtures/valid/v32_reserved_name_near_misses.toml new file mode 100644 index 00000000..ad5e5023 --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v32_reserved_name_near_misses.toml @@ -0,0 +1,43 @@ +# Fixture v32_reserved_name_near_misses +# Expected: PASS +# Source: Owner decision AQ4 +# 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, ...). +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.CTRL] +source_folder = "CTRL" + +[channels.c1] + +[channels.t0] + +[channels.z2] + +[channels.label] + +[repeats.rep1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep1.members.CTRL] +source_repeat = "rep1" +image_count = 4 + +[repeats.rep1.members.CTRL.technical_repeats.single] +source_folder = "single" +channels = ["c1", "t0", "z2", "label"] + +[masks.Patches] +source_type = "patches" +representation = "instances" + +[masks.Labels] +source_type = "labels" +representation = "binary" + +[used_ids] +conditions = ["CTRL"] +channels = ["c1", "label", "t0", "z2"] +repeats = ["rep1"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/valid/v33_condition_folder_on_forbidden_list.toml b/tests/spec_fixtures/fixtures/valid/v33_condition_folder_on_forbidden_list.toml new file mode 100644 index 00000000..0617c7fc --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v33_condition_folder_on_forbidden_list.toml @@ -0,0 +1,71 @@ +# Fixture v33_condition_folder_on_forbidden_list +# Expected: PASS +# Source: Owner decision of 6 Nov 2026 (fourth round: folder labels never restricted) +# 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). +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.ctrl] +source_folder = "control" +display_name = "control" + +[conditions.DrugA] +source_folder = "DrugA" +display_name = "Drug A" +reference = "ctrl" +target = "SRC" +treatment_key = "saracatinib" + +[channels.DNA] +display_name = "DAPI" +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 = ["DrugA", "ctrl"] +channels = ["ACTIN", "DNA"] +repeats = ["rep1", "rep2"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/fixtures/valid/v34_technical_repeat_folder_on_forbidden_list.toml b/tests/spec_fixtures/fixtures/valid/v34_technical_repeat_folder_on_forbidden_list.toml new file mode 100644 index 00000000..b94a78e9 --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v34_technical_repeat_folder_on_forbidden_list.toml @@ -0,0 +1,81 @@ +# Fixture v34_technical_repeat_folder_on_forbidden_list +# Expected: PASS +# Source: Owner decision of 6 Nov 2026 (fourth round: folder labels never restricted) +# 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. +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" +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.acq1] +source_folder = "files" +display_name = "files" +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.CTRL.technical_repeats.acq2] +source_folder = "overrides" +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.DrugA] +source_repeat = "rep1" +image_count = 11 + +[repeats.rep1.members.DrugA.technical_repeats.acq1] +source_folder = "files" +display_name = "files" +channels = ["DNA", "ACTIN"] + +[repeats.rep1.members.DrugA.technical_repeats.acq2] +source_folder = "overrides" +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 = ["acq1", "acq2", "single"] diff --git a/tests/spec_fixtures/fixtures/valid/v35_flat_experiment.toml b/tests/spec_fixtures/fixtures/valid/v35_flat_experiment.toml new file mode 100644 index 00000000..bcf74856 --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v35_flat_experiment.toml @@ -0,0 +1,34 @@ +# 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). +schema = "morphobricks-experiment" +schema_version = 1 + +[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.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/valid/v36_repeat_folder_forbidden_name.toml b/tests/spec_fixtures/fixtures/valid/v36_repeat_folder_forbidden_name.toml new file mode 100644 index 00000000..790873f3 --- /dev/null +++ b/tests/spec_fixtures/fixtures/valid/v36_repeat_folder_forbidden_name.toml @@ -0,0 +1,67 @@ +# 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). +schema = "morphobricks-experiment" +schema_version = 1 + +[conditions.DrugA] +source_folder = "DrugA" +reference = "ctrl" + +[conditions.ctrl] +source_folder = "control" +display_name = "control" + +[channels.ACTIN] +header_names = ["Actin"] + +[channels.DNA] +display_name = "DAPI" +header_names = ["DAPI"] + +[repeats.R1] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.R1.members.DrugA] +source_repeat = "z" +image_count = 2 + +[repeats.R1.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.R1.members.ctrl] +source_repeat = "z" +image_count = 2 + +[repeats.R1.members.ctrl.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.rep2] +confirmed = true +confirmed_on = "2026-11-06" + +[repeats.rep2.members.DrugA] +source_repeat = "rep2" +image_count = 2 + +[repeats.rep2.members.DrugA.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[repeats.rep2.members.ctrl] +source_repeat = "rep2" +image_count = 2 + +[repeats.rep2.members.ctrl.technical_repeats.single] +source_folder = "single" +channels = ["DNA", "ACTIN"] + +[used_ids] +conditions = ["DrugA", "ctrl"] +channels = ["ACTIN", "DNA"] +repeats = ["R1", "rep2"] +technical_repeats = ["single"] diff --git a/tests/spec_fixtures/tools/audit/refval.py b/tests/spec_fixtures/tools/audit/refval.py new file mode 100644 index 00000000..bff88079 --- /dev/null +++ b/tests/spec_fixtures/tools/audit/refval.py @@ -0,0 +1,875 @@ +"""AUDIT TOOL ONLY: throwaway reference validator for MorphoBricks experiment description v1. + +This is not a runtime package and no brick may import it. It implements the normative text of +docs/experiment-v1.md (phases 1-5 of section 6 and the warnings), the canonical writer of +section 9.1, the section digests of section 9.3 and the predecessor checks of sections 4.4a, +4.6 and 4.9 that two TOML files can express. Where the text is ambiguous the choice made here is +marked "AMBIGUITY" and reported in docs/audits/matrix-audit-2026-11-07.md. + +Usage: + python -I refval.py check FILE [--json] + python -I refval.py fixtures SPEC_ROOT # run the fixture catalogue + python -I refval.py transition OLD NEW +""" + +from __future__ import annotations + +import hashlib +import json +import re +import sys +import tomllib +import unicodedata +from datetime import date +from pathlib import Path + +SENTINELS = {"", "na", "nan", "none", "null", "n/a", ""} +TOKEN = re.compile(r"^[A-Za-z][A-Za-z0-9]*(?:_[A-Za-z0-9]+)*$") +DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$") +RESERVED = { + "whole", "cell", "nucleus", "cytoplasm", "ring", "expanded_nucleus", + "condition", "biological_repeat", "repeat", "technical_repeat", "position_id", + "image_file", "image_path", "mask_file", "patch_file", "patch_id", "label_id", + "z", "z0", "z1", "c", "t", +} +FORBIDDEN = { + "axes", "shared", "plane_scope", "z", "projection", "pixel_size", "calibration", "fovs", + "files", "paths", "overrides", "exclude", "fingerprints", "bricks", "routes", "features", + "normalization", "statistics", "contrasts", "binary_mask_policy", "mask_conversion", + "control", "roles", "experiment", "source_index", "channel_count", "retired_repeats", + "replaced_by", "labchronicle", "provenance", "source_database", +} +TOP = ("schema", "schema_version", "conditions", "channels", "repeats", "masks", "used_ids") +COND = ("source_folder", "display_name", "reference", "target", "treatment_key") +CHAN = ("display_name", "header_names") +REP = ("display_name", "confirmed", "confirmed_on", "members") +MEM = ("source_repeat", "image_count", "technical_repeats") +UNIT = ("source_folder", "display_name", "channels", "image_type") +MASK = ("source_type", "representation", "source_channels", "display_name") +USED = ("conditions", "channels", "repeats", "technical_repeats") +REPRESENTATIONS = ("instances", "binary") + + +class Report: + def __init__(self) -> None: + self.errors: list[tuple[str, str, object]] = [] + self.warnings: list[tuple[str, str, object]] = [] + self.phase = 0 + + def e(self, code: str, path: str, value: object = None) -> None: + self.errors.append((code, path, value)) + + def w(self, code: str, path: str, value: object = None) -> None: + self.warnings.append((code, path, value)) + + @property + def codes(self) -> set[str]: + return {c for c, _, _ in self.errors} + + @property + def warning_codes(self) -> set[str]: + return {c for c, _, _ in self.warnings} + + def as_dict(self) -> dict: + return { + "phase": self.phase, + "errors": [list(map(str, x)) for x in self.errors], + "warnings": [list(map(str, x)) for x in self.warnings], + "error_codes": sorted(self.codes), + "warning_codes": sorted(self.warning_codes), + } + + +def nfc(s: str) -> str: + return unicodedata.normalize("NFC", s) + + +def is_text(x: object) -> bool: + return type(x) is str + + +def is_table(x: object) -> bool: + return type(x) is dict + + +def kpath(base: str, key: str) -> str: + return f"{base}.{key}" if re.fullmatch(r"[A-Za-z0-9_-]+", key) else f'{base}."{key}"' + + +# ------------------------------------------------------------------ phase 1: decode +def phase1(data: bytes, r: Report): + if data.startswith(b"\xef\xbb\xbf"): + r.e("E_FILE_ENCODING", "", "byte-order mark") + return None + try: + text = data.decode("utf-8") + except UnicodeDecodeError as exc: + r.e("E_FILE_ENCODING", "", str(exc)) + return None + try: + 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) + r.e("E_DUPLICATE_KEY" if dup else "E_TOML_SYNTAX", "", msg) + return None + + +# ------------------------------------------------------------------ phase 2: header +def phase2(doc: dict, r: Report) -> None: + if "schema" not in doc: + r.e("E_SCHEMA_ID", "schema") + elif not is_text(doc["schema"]): + r.e("E_TYPE", "schema", doc["schema"]) + elif doc["schema"] != "morphobricks-experiment": + r.e("E_SCHEMA_ID", "schema", doc["schema"]) + if "schema_version" not in doc: + r.e("E_SCHEMA_VERSION_MISSING", "schema_version") + elif type(doc["schema_version"]) is not int: + r.e("E_TYPE", "schema_version", doc["schema_version"]) + elif doc["schema_version"] != 1: + r.e("E_SCHEMA_VERSION_UNSUPPORTED", "schema_version", doc["schema_version"]) + + +# ------------------------------------------------------------------ phase 3: structure +def _fields(tbl: dict, allowed, path: str, r: Report) -> None: + for k in tbl: + if k in FORBIDDEN: + r.e("E_FORBIDDEN_FIELD", kpath(path, k), k) + elif k not in allowed: + r.e("E_UNKNOWN_FIELD", kpath(path, k), k) + + +def _required(tbl: dict, keys, path: str, r: Report) -> None: + for k in keys: + if k not in tbl: + r.e("E_MISSING_FIELD", kpath(path, k)) + + +def _text(tbl: dict, k: str, path: str, r: Report, nonempty: bool = False) -> None: + if k not in tbl: + return + v = tbl[k] + if not is_text(v): + r.e("E_TYPE", kpath(path, k), v) + elif nonempty and v.strip() == "": + r.e("E_TEXT_EMPTY", kpath(path, k), v) + + +def _text_array(tbl: dict, k: str, path: str, r: Report, nonempty_items: bool = False, + min1: bool = False) -> None: + if k not in tbl: + return + v = tbl[k] + p = kpath(path, k) + if type(v) is not list: + r.e("E_TYPE", p, v) + return + if min1 and not v: + r.e("E_EMPTY", p) + for i, x in enumerate(v): + if not is_text(x): + r.e("E_TYPE", f"{p}[{i}]", x) + elif nonempty_items and x.strip() == "": + r.e("E_TEXT_EMPTY", f"{p}[{i}]", x) + + +def _id_table(parent: dict, k: str, path: str, r: Report, required: bool = True, + min1: bool = True): + """Return [(id, table)] of an ID-keyed table; forbidden ID keys are reported and skipped.""" + p = kpath(path, k) + if k not in parent: + if required: + r.e("E_MISSING_FIELD", p) + return [] + t = parent[k] + if not is_table(t): + r.e("E_TYPE", p, t) + return [] + if min1 and not t: + r.e("E_EMPTY", p) + out = [] + for key, v in t.items(): + if key in FORBIDDEN: + r.e("E_FORBIDDEN_FIELD", kpath(p, key), key) + continue + if not is_table(v): + r.e("E_TYPE", kpath(p, key), v) + continue + out.append((key, v)) + return out + + +def phase3(doc: dict, r: Report) -> None: + _fields(doc, TOP, "", r) + _required(doc, ("conditions", "channels", "repeats", "used_ids"), "", r) + for cid, c in _id_table(doc, "conditions", "", r): + p = kpath("conditions", cid) + _fields(c, COND, p, r) + _required(c, ("source_folder",), p, r) + _text(c, "source_folder", p, r) + _text(c, "display_name", p, r, nonempty=True) + _text(c, "reference", p, r) + _text(c, "target", p, r, nonempty=True) + _text(c, "treatment_key", p, r, nonempty=True) + for chid, ch in _id_table(doc, "channels", "", r): + p = kpath("channels", chid) + _fields(ch, CHAN, p, r) + _text(ch, "display_name", p, r, nonempty=True) + _text_array(ch, "header_names", p, r, nonempty_items=True) + for rid, rep in _id_table(doc, "repeats", "", r): + p = kpath("repeats", rid) + _fields(rep, REP, p, r) + _required(rep, ("confirmed", "confirmed_on", "members"), p, r) + _text(rep, "display_name", p, r, nonempty=True) + if "confirmed" in rep: + if type(rep["confirmed"]) is not bool: + r.e("E_TYPE", p + ".confirmed", rep["confirmed"]) + elif rep["confirmed"] is not True: + r.e("E_REPEAT_UNCONFIRMED", p + ".confirmed", False) + if "confirmed_on" in rep: + v = rep["confirmed_on"] + if not is_text(v): + r.e("E_TYPE", p + ".confirmed_on", v) + else: + ok = bool(DATE.fullmatch(v)) + if ok: + try: + date.fromisoformat(v) + except ValueError: + ok = False + if not ok: + r.e("E_DATE_FORMAT", p + ".confirmed_on", v) + for cid, m in _id_table(rep, "members", p, r): + mp = kpath(p + ".members", cid) + _fields(m, MEM, mp, r) + _required(m, ("source_repeat", "image_count", "technical_repeats"), mp, r) + _text(m, "source_repeat", mp, r) + if "image_count" in m: + v = m["image_count"] + if type(v) is not int: + r.e("E_TYPE", mp + ".image_count", v) + elif v < 1: + r.e("E_VALUE_RANGE", mp + ".image_count", v) + for tid, u in _id_table(m, "technical_repeats", mp, r): + up = kpath(mp + ".technical_repeats", tid) + _fields(u, UNIT, up, r) + _required(u, ("source_folder", "channels"), up, r) + _text(u, "source_folder", up, r) + _text(u, "display_name", up, r, nonempty=True) + _text_array(u, "channels", up, r, min1=True) + if "image_type" in u: + v = u["image_type"] + if not is_text(v): + r.e("E_TYPE", up + ".image_type", v) + elif v != "rgb": + r.e("E_ENUM", up + ".image_type", v) + for mid, mk in _id_table(doc, "masks", "", r, required=False, min1=False): + p = kpath("masks", mid) + _fields(mk, MASK, p, r) + _required(mk, ("source_type", "representation"), p, r) + _text(mk, "source_type", p, r) + _text(mk, "display_name", p, r, nonempty=True) + _text_array(mk, "source_channels", p, r) + if "representation" in mk and is_text(mk["representation"]): + if mk["representation"] not in REPRESENTATIONS: + r.e("E_ENUM", p + ".representation", mk["representation"]) + elif "representation" in mk: + r.e("E_TYPE", p + ".representation", mk["representation"]) + if "used_ids" in doc: + u = doc["used_ids"] + if not is_table(u): + r.e("E_TYPE", "used_ids", u) + else: + _fields(u, USED, "used_ids", r) + _required(u, USED, "used_ids", r) + for k in USED: + _text_array(u, k, "used_ids", r, min1=True) + + +# ------------------------------------------------------------------ helpers over a valid structure +def members(doc: dict): + for rid, rep in doc.get("repeats", {}).items(): + for cid, m in rep.get("members", {}).items(): + yield rid, cid, m + + +def units(doc: dict): + for rid, cid, m in members(doc): + for tid, u in m.get("technical_repeats", {}).items(): + yield rid, cid, tid, u + + +def upath(rid: str, cid: str, tid: str) -> str: + return kpath(kpath(kpath("repeats", rid) + ".members", cid) + ".technical_repeats", tid) + + +# ------------------------------------------------------------------ phase 4: identity +def _label(v: str, path: str, r: Report) -> None: + if v != v.strip() or v.casefold() in SENTINELS: + r.e("E_ID_TEXT", path, v) + + +def _nfc(v: str, path: str, r: Report) -> None: + if nfc(v) != v: + r.e("E_ID_NOT_NFC", path, v) + + +def _token(v: str, path: str, r: Report) -> None: + _label(v, path, r) + if not TOKEN.fullmatch(v): + r.e("E_ID_SYNTAX", path, v) + + +def _single(v: str, path: str, r: Report) -> None: + if v.casefold() == "single" and v != "single": + r.e("E_SINGLE_MISUSE", path, v) + + +def phase4(doc: dict, r: Report) -> None: + scopes: dict[str, list[tuple[str, str]]] = {k: [] for k in ( + "conditions", "condition_folders", "repeats", "source_repeats", "technical_repeats", + "technical_folders", "channels", "masks", "mask_types")} + + def label_id(v, path, scope): + _label(v, path, r) + _nfc(v, path, r) + _single(v, path, r) + scopes[scope].append((v, path)) + + for cid, c in doc["conditions"].items(): + p = kpath("conditions", cid) + label_id(cid, p, "conditions") + label_id(c["source_folder"], p + ".source_folder", "condition_folders") + if "reference" in c: + _nfc(c["reference"], p + ".reference", r) # a reference is NFC-checked only + for chid in doc["channels"]: + p = kpath("channels", chid) + _token(chid, p, r) + _nfc(chid, p, r) + _single(chid, p, r) + if chid.casefold() in RESERVED: + r.e("E_ID_RESERVED", p, chid) + scopes["channels"].append((chid, p)) + for rid, rep in doc["repeats"].items(): + p = kpath("repeats", rid) + label_id(rid, p, "repeats") + cond_members: dict[str, list[tuple[str, str]]] = {} + for rid, cid, m in members(doc): + mp = kpath(kpath("repeats", rid) + ".members", cid) + _nfc(cid, mp, r) # a member key is a reference to a condition ID + label_id(m["source_repeat"], mp + ".source_repeat", "source_repeats") + cond_members.setdefault(cid, []).append((m["source_repeat"], mp)) + tids = list(m["technical_repeats"]) + for tid, u in m["technical_repeats"].items(): + up = upath(rid, cid, tid) + label_id(tid, up, "technical_repeats") + label_id(u["source_folder"], up + ".source_folder", "technical_folders") + if tid == "single" and len(tids) > 1: + r.e("E_SINGLE_MISUSE", up, "single is not the only technical repeat") + if (tid == "single") != (u["source_folder"] == "single"): + r.e("E_SINGLE_MISUSE", up + ".source_folder", u["source_folder"]) + for i, ch in enumerate(u["channels"]): + _nfc(ch, f"{up}.channels[{i}]", r) + for cid, lst in cond_members.items(): + if any(s == "single" for s, _ in lst) and len(lst) > 1: + for s, mp in lst: + if s == "single": + r.e("E_SINGLE_MISUSE", mp + ".source_repeat", "single is not the only member") + for mid, mk in doc.get("masks", {}).items(): + p = kpath("masks", mid) + _token(mid, p, r) + _nfc(mid, p, r) + _single(mid, p, r) + parts = mid.casefold().split("_") + if mid.casefold() in RESERVED or "in" in parts: + r.e("E_ID_RESERVED", p, mid) + scopes["masks"].append((mid, p)) + label_id(mk["source_type"], p + ".source_type", "mask_types") + for i, ch in enumerate(mk.get("source_channels", [])): + _nfc(ch, f"{p}.source_channels[{i}]", r) + for kind in USED: + for i, v in enumerate(doc["used_ids"][kind]): + p = f"used_ids.{kind}[{i}]" + if kind == "channels": + # AMBIGUITY: "the identity rules of their kind" -- Label + Token + NFC applied; + # the reserved list is not applied to used_ids entries (no fixture decides it). + _token(v, p, r) + _nfc(v, p, r) + else: + _label(v, p, r) + _nfc(v, p, r) + _single(v, p, r) + scopes[kind].append((v, p)) + for scope, items in scopes.items(): + by_fold: dict[str, set[str]] = {} + for v, _ in items: + by_fold.setdefault(nfc(v).casefold(), set()).add(nfc(v)) + for v, p in items: + if len(by_fold[nfc(v).casefold()]) > 1: + r.e("E_ID_CASE_COLLISION", p, v) + + +# ------------------------------------------------------------------ phase 5: semantics +def dn_key(x: str) -> str: + return nfc(nfc(x).casefold()) + + +def phase5(doc: dict, r: Report) -> None: + conds = doc["conditions"] + chans = doc["channels"] + for rid, cid, m in members(doc): + if cid not in conds: + r.e("E_UNKNOWN_CONDITION", kpath(kpath("repeats", rid) + ".members", cid), cid) + for rid, cid, tid, u in units(doc): + up = upath(rid, cid, tid) + seen = set() + for i, ch in enumerate(u["channels"]): + if ch not in chans: + r.e("E_UNKNOWN_CHANNEL", f"{up}.channels[{i}]", ch) + if ch in seen: + r.e("E_DUPLICATE_CHANNEL_IN_UNIT", f"{up}.channels[{i}]", ch) + seen.add(ch) + if u.get("image_type") == "rgb": + if u["channels"] != ["R", "G", "B"]: + r.e("E_RGB_UNIT_CHANNELS", up + ".channels", u["channels"]) + else: + for i, ch in enumerate(u["channels"]): + if ch.casefold() in ("r", "g", "b"): + r.e("E_RGB_TOKEN_MISUSE", f"{up}.channels[{i}]", ch) + for chid, ch in chans.items(): + hn = ch.get("header_names", []) + if chid.casefold() in ("r", "g", "b") and hn: + r.e("E_RGB_ALIAS", kpath("channels", chid) + ".header_names", hn) + if len({nfc(x) for x in hn}) != len(hn): + r.e("E_DUPLICATE_VALUE", kpath("channels", chid) + ".header_names", hn) + for mid, mk in doc.get("masks", {}).items(): + sc = mk.get("source_channels", []) + for i, ch in enumerate(sc): + if ch not in chans: + r.e("E_UNKNOWN_CHANNEL", f"{kpath('masks', mid)}.source_channels[{i}]", ch) + if len(set(sc)) != len(sc): + r.e("E_DUPLICATE_VALUE", kpath("masks", mid) + ".source_channels", sc) + # display names + groups: dict[str, list[tuple[str, str, str]]] = {k: [] for k in + ("c", "ch", "r", "t", "m")} + for k, t in (("c", conds), ("ch", chans), ("r", doc["repeats"]), + ("m", doc.get("masks", {}))): + for i, v in t.items(): + if "display_name" in v: + groups[k].append((i, v["display_name"], kpath(k, i))) + for rid, cid, tid, u in units(doc): + if "display_name" in u: + groups["t"].append((tid, u["display_name"], upath(rid, cid, tid))) + for items in groups.values(): + owners: dict[str, set[str]] = {} + for owner, name, _ in items: + owners.setdefault(dn_key(name), set()).add(owner) + for owner, name, p in items: + if len(owners[dn_key(name)]) > 1: + r.e("E_DISPLAY_NAME_COLLISION", p + ".display_name", name) + # used_ids + used = doc["used_ids"] + declared = { + "conditions": set(conds), "channels": set(chans), "repeats": set(doc["repeats"]), + "technical_repeats": {tid for _, _, tid, _ in units(doc)}, + } + for kind in USED: + lst = used[kind] + if len(set(lst)) != len(lst): + r.e("E_DUPLICATE_VALUE", f"used_ids.{kind}", lst) + for i in sorted(declared[kind] - set(lst)): + r.e("E_USED_ID_MISSING", f"used_ids.{kind}", i) + # one folder, one ID + folder_ids: dict[str, set[str]] = {} + for cid, c in conds.items(): + folder_ids.setdefault(c["source_folder"], set()).add(cid) + for f, ids in folder_ids.items(): + if len(ids) > 1: + r.e("E_SOURCE_FOLDER_REUSED", "conditions", (f, sorted(ids))) + tfolder_ids: dict[str, set[str]] = {} + tid_folders: dict[str, set[str]] = {} + for rid, cid, tid, u in units(doc): + tfolder_ids.setdefault(u["source_folder"], set()).add(tid) + tid_folders.setdefault(tid, set()).add(u["source_folder"]) + for f, ids in tfolder_ids.items(): + if len(ids) > 1: + r.e("E_SOURCE_FOLDER_REUSED", "technical_repeats", (f, sorted(ids))) + for tid, fs in tid_folders.items(): + if len(fs) > 1: + r.e("E_SOURCE_FOLDER_CONFLICT", tid, sorted(fs)) + # conditions with members + member_conds = {cid for _, cid, _ in members(doc)} + for cid in conds: + if cid not in member_conds: + r.e("E_CONDITION_WITHOUT_MEMBERS", kpath("conditions", cid), cid) + # source repeats + owner: dict[tuple[str, str], set[str]] = {} + for rid, cid, m in members(doc): + owner.setdefault((cid, m["source_repeat"]), set()).add(rid) + for key, rids in owner.items(): + if len(rids) > 1: + r.e("E_SOURCE_REPEAT_REUSED", "repeats", (key, sorted(rids))) + for rid in doc["repeats"]: + for rid2, cid, m in members(doc): + if rid2 != rid and nfc(m["source_repeat"]).casefold() == nfc(rid).casefold(): + r.e("E_REPEAT_ID_SHADOWS_SOURCE", + kpath(kpath("repeats", rid2) + ".members", cid), (rid, m["source_repeat"])) + types: dict[str, set[str]] = {} + for mid, mk in doc.get("masks", {}).items(): + types.setdefault(mk["source_type"], set()).add(mid) + for t, ids in types.items(): + if len(ids) > 1: + r.e("E_MASK_SOURCE_TYPE_REUSED", "masks", (t, sorted(ids))) + # reference graph + edges = {} + for cid, c in conds.items(): + if "reference" in c: + ref = c["reference"] + p = kpath("conditions", cid) + ".reference" + if ref == cid: + r.e("E_REFERENCE_SELF", p, ref) + elif ref not in conds: + r.e("E_REFERENCE_UNKNOWN", p, ref) + else: + edges[cid] = ref + reported = set() + for start in edges: + seen = [] + cur = start + while cur in edges and cur not in seen: + seen.append(cur) + cur = edges[cur] + if cur in seen: + cyc = frozenset(seen[seen.index(cur):]) + if cyc not in reported: + reported.add(cyc) + r.e("E_REFERENCE_CYCLE", "conditions", sorted(cyc)) + + +def warnings(doc: dict, r: Report) -> None: + used = {ch for _, _, _, u in units(doc) for ch in u["channels"]} + for chid in doc["channels"]: + if chid not in used: + r.w("W_UNUSED_CHANNEL", kpath("channels", chid), chid) + conds = set(doc["conditions"]) + rep_conds = {rid: set(rep["members"]) for rid, rep in doc["repeats"].items()} + for rid, cs in rep_conds.items(): + if cs != conds: + r.w("W_REPEAT_MISSING_CONDITIONS", kpath("repeats", rid), sorted(conds - cs)) + for cid, c in doc["conditions"].items(): + if "reference" in c: + if not any(cid in cs and c["reference"] in cs for cs in rep_conds.values()): + r.w("W_REFERENCE_UNPAIRED", kpath("conditions", cid), c["reference"]) + + +def validate(data: bytes) -> tuple[Report, dict | None]: + r = Report() + doc = phase1(data, r) + if r.errors: + r.phase = 1 + return r, None + for n, fn in ((2, phase2), (3, phase3), (4, phase4), (5, phase5)): + fn(doc, r) + if r.errors: + r.phase = n + return r, doc + warnings(doc, r) + return r, doc + + +# ------------------------------------------------------------------ canonical writer (section 9.1) +def _key(k: str) -> str: + return k if re.fullmatch(r"[A-Za-z0-9_-]+", k) else _str(k) + + +def _str(s: str) -> str: + out = ['"'] + esc = {'"': '\\"', "\\": "\\\\", "\b": "\\b", "\t": "\\t", "\n": "\\n", "\f": "\\f", + "\r": "\\r"} + for ch in s: + if ch in esc: + out.append(esc[ch]) + elif ord(ch) < 0x20 or ord(ch) == 0x7F: + out.append(f"\\u{ord(ch):04X}") + else: + out.append(ch) + out.append('"') + return "".join(out) + + +def _val(v) -> str: + if type(v) is bool: + return "true" if v else "false" + if type(v) is int: + return str(v) + if type(v) is str: + return _str(v) + if type(v) is list: + return "[" + ", ".join(_val(x) for x in v) + "]" + raise TypeError(type(v)) + + +def _block(header: str, tbl: dict, order, sort_arrays=()) -> list[str]: + lines = [f"[{header}]"] + for k in order: + if k in tbl and not is_table(tbl[k]): + v = tbl[k] + if k in sort_arrays: + v = sorted(v) + lines.append(f"{k} = {_val(v)}") + return lines + [""] + + +def write(doc: dict) -> bytes: + """Canonical bytes of a valid document (experiment-v1 section 9.1).""" + out = [f"schema = {_str(doc['schema'])}", f"schema_version = {doc['schema_version']}", ""] + for cid in sorted(doc["conditions"]): + out += _block("conditions." + _key(cid), doc["conditions"][cid], COND) + for chid in sorted(doc["channels"]): + out += _block("channels." + _key(chid), doc["channels"][chid], CHAN, ("header_names",)) + for rid in sorted(doc["repeats"]): + rep = doc["repeats"][rid] + rh = "repeats." + _key(rid) + out += _block(rh, rep, REP) + for cid in sorted(rep["members"]): + m = rep["members"][cid] + mh = rh + ".members." + _key(cid) + out += _block(mh, m, MEM) + for tid in sorted(m["technical_repeats"]): + out += _block(mh + ".technical_repeats." + _key(tid), + m["technical_repeats"][tid], UNIT) + for mid in sorted(doc.get("masks", {})): + out += _block("masks." + _key(mid), doc["masks"][mid], MASK, ("source_channels",)) + out.append("[used_ids]") + for k in USED: + out.append(f"{k} = {_val(sorted(doc['used_ids'][k]))}") + return ("\n".join(out) + "\n").encode("utf-8") + + +def model(doc): + """Parsed document with the arrays whose order is not semantic sorted (section 9.2).""" + def walk(x, key=None): + if is_table(x): + return {k: walk(v, k) for k, v in x.items()} + if type(x) is list: + return sorted(x) if key in ("header_names", "source_channels") else list(x) + return x + d = walk(doc) + d["used_ids"] = {k: sorted(v) for k, v in d.get("used_ids", {}).items()} + return d + + +# ------------------------------------------------------------------ digests (section 9.3) +DESCRIPTIVE = {"display_name", "target", "treatment_key"} + + +def _strip(x): + if is_table(x): + return {k: _strip(v) for k, v in x.items() if k not in DESCRIPTIVE} + if type(x) is list: + return [_strip(v) for v in x] + return x + + +def section_digests(doc: dict) -> dict[str, str]: + """SHA-256 of the canonical serialization of each stripped section. + + AMBIGUITY: section 9.3 does not define the byte form of a section alone; the canonical writer + is applied to a document holding only that section (other required sections empty). + """ + out = {} + s = _strip(doc) + for sec in ("conditions", "channels", "repeats", "masks"): + part = {"schema": "", "schema_version": 1, "conditions": {}, "channels": {}, + "repeats": {}, "masks": {}, "used_ids": {k: [] for k in USED}} + part[sec] = s.get(sec, {}) + out[sec] = hashlib.sha256(write(part)).hexdigest() + return out + + +# ------------------------------------------------------------------ transitions (R_*) +def transition(old: dict, new: dict) -> set[str]: + codes: set[str] = set() + for k in USED: + ou, nu = set(old["used_ids"][k]), set(new["used_ids"][k]) + if ou - nu: + codes.add("R_USED_ID_DROPPED") + old_declared = (set(old[k]) if k != "technical_repeats" + else {t for _, _, t, _ in units(old)}) + new_declared = (set(new[k]) if k != "technical_repeats" + else {t for _, _, t, _ in units(new)}) + if (ou - old_declared) & new_declared: + codes.add("R_USED_ID_REUSED") + + def rep_folders(doc, rid): + return frozenset((c, m["source_repeat"]) for c, m in doc["repeats"][rid]["members"].items()) + + def cond_folders(doc, cid): + return {m["source_repeat"] for _, c, m in members(doc) if c == cid} + + def tech(doc): + out: dict[str, tuple[set, set]] = {} + for rid, cid, tid, u in units(doc): + src = doc["repeats"][rid]["members"][cid]["source_repeat"] + f, ms = out.setdefault(tid, (set(), set())) + f.add(u["source_folder"]) + ms.add((cid, src)) + return out + + def positions(doc): + pos: dict[str, set] = {} + for rid, cid, tid, u in units(doc): + src = doc["repeats"][rid]["members"][cid]["source_repeat"] + for i, ch in enumerate(u["channels"]): + pos.setdefault(ch, set()).add((cid, src, tid, i)) + return pos + + # (a) same member folders, other repeat ID + old_by_f = {rep_folders(old, r): r for r in old["repeats"]} + for rid in new["repeats"]: + f = rep_folders(new, rid) + if f in old_by_f and old_by_f[f] != rid: + codes.add("R_CONFIRMED_ID_CHANGED") + # (b) channel ID replaced at every position with the same header evidence + op, np_ = positions(old), positions(new) + for ch, ps in op.items(): + if ch in new["channels"]: + continue + for nch, nps in np_.items(): + if nch not in old["channels"] and nps == ps and \ + sorted(old["channels"][ch].get("header_names", [])) == \ + sorted(new["channels"][nch].get("header_names", [])): + codes.add("R_CONFIRMED_ID_CHANGED") + # (b2) same source_folder, other condition / technical repeat ID + old_cf = {c["source_folder"]: cid for cid, c in old["conditions"].items()} + for cid, c in new["conditions"].items(): + if c["source_folder"] in old_cf and old_cf[c["source_folder"]] != cid: + codes.add("R_CONFIRMED_ID_CHANGED") + ot, nt = tech(old), tech(new) + old_tf = {f: tid for tid, (fs, _) in ot.items() for f in fs} + for tid, (fs, _) in nt.items(): + for f in fs: + if f in old_tf and old_tf[f] != tid: + codes.add("R_CONFIRMED_ID_CHANGED") + # (c) an ID declared in both names a changed entity (something removed or reassigned) + for rid in set(old["repeats"]) & set(new["repeats"]): + if not rep_folders(old, rid) <= rep_folders(new, rid): + codes.add("R_CONFIRMED_ID_CHANGED") + for cid in set(old["conditions"]) & set(new["conditions"]): + if old["conditions"][cid]["source_folder"] != new["conditions"][cid]["source_folder"] \ + or not cond_folders(old, cid) <= cond_folders(new, cid): + codes.add("R_CONFIRMED_ID_CHANGED") + for tid in set(ot) & set(nt): + if ot[tid][0] != nt[tid][0] or not ot[tid][1] <= nt[tid][1]: + codes.add("R_CONFIRMED_ID_CHANGED") + for ch in set(old["channels"]) & set(new["channels"]): + if not op.get(ch, set()) <= np_.get(ch, set()) or \ + not set(old["channels"][ch].get("header_names", [])) <= \ + set(new["channels"][ch].get("header_names", [])): + codes.add("R_CONFIRMED_ID_CHANGED") + # confirmed_on of a repeat whose confirmation is not reopened + for rid in set(old["repeats"]) & set(new["repeats"]): + o, n = old["repeats"][rid], new["repeats"][rid] + if _strip(o["members"]) == _strip(n["members"]) and o["confirmed_on"] != n["confirmed_on"]: + codes.add("R_CONFIRMED_ON_CHANGED") + return codes + + +# ------------------------------------------------------------------ fixture catalogue +CODE = re.compile(r"\b([EWR]_[A-Z_]+)\b") + + +def run_fixtures(root: Path) -> int: + fx = root / "fixtures" + readme = (fx / "README.md").read_text(encoding="utf-8") + rows = {} + for line in readme.splitlines(): + m = re.match(r"\| \[`([vit]\d+[^`]*)`\]\([^)]+\) \|(.*)", line) + if m: + rows[m.group(1)] = [c.strip() for c in m.group(2).split("|")] + bad = 0 + docs = {} + counts = {} + for sub in ("valid", "invalid", "transitions"): + for f in sorted((fx / sub).glob("*.toml"), key=lambda p: int(re.match(r"\D(\d+)", p.stem)[1])): + data = f.read_bytes() + r, doc = validate(data) + docs[f.stem.split("_")[0]] = doc + row = rows.get(f.stem) + if row is None: + print(f"NO-CATALOGUE-ROW {sub}/{f.stem}") + bad += 1 + continue + if sub == "invalid": + exp = set(CODE.findall(row[1])) + ok = r.codes == exp and not r.warnings and str(r.phase) == row[2] + got = f"phase={r.phase} {sorted(r.codes)}" + want = f"phase={row[2]} {sorted(exp)}" + else: + cell = row[1] if sub == "valid" else row[2] + expw = {c for c in CODE.findall(cell) if c.startswith("W_")} + ok = not r.errors and r.warning_codes == expw + got = f"errors={sorted(r.codes)} warnings={sorted(r.warning_codes)}" + want = f"warnings={sorted(expw)}" + counts[sub] = counts.get(sub, 0) + 1 + if not ok: + bad += 1 + print(f"MISMATCH {sub}/{f.stem}: got {got}; expected {want}") + for e in r.errors[:5]: + print(" ", e) + for f in sorted((fx / "transitions").glob("*.toml")): + row = rows[f.stem] + pred = re.search(r"`valid/(v\d+)`|valid/(v\d+)", row[0] + " " + row[1] + " " + row[2]) + pred = pred.group(1) or pred.group(2) + exp = {c for c in CODE.findall(row[1]) if c.startswith("R_")} + got = transition(docs[pred], docs[f.stem.split("_")[0]]) + if got != exp: + bad += 1 + print(f"MISMATCH transition {f.stem} vs {pred}: got {sorted(got)} expected {sorted(exp)}") + counts["transition-checks"] = counts.get("transition-checks", 0) + 1 + # round trip and digests + for k, d in docs.items(): + if k.startswith("v") or k.startswith("t"): + once = write(d) + if model(tomllib.loads(once.decode())) != model(d) or \ + write(tomllib.loads(once.decode())) != once: + bad += 1 + print(f"MISMATCH round trip {k}") + if docs["v18"] != docs["v01"]: + bad += 1 + print("MISMATCH v18 model != v01") + for k in ("v16", "v21"): + if section_digests(docs[k]) != section_digests(docs["v01"]): + bad += 1 + print(f"MISMATCH {k} digests != v01") + print(f"checked {counts}; round trip on {sum(1 for k in docs if k[0] in 'vt')} files; " + f"mismatches: {bad}") + return bad + + +def main(argv: list[str]) -> int: + if len(argv) >= 2 and argv[0] == "check": + r, _ = validate(Path(argv[1]).read_bytes()) + if "--json" in argv: + print(json.dumps(r.as_dict(), ensure_ascii=False)) + else: + for e in r.errors: + print("ERROR", *e) + for w in r.warnings: + print("WARN ", *w) + print("phase", r.phase, "OK" if not r.errors else "REFUSED") + return 1 if r.errors else 0 + if len(argv) == 2 and argv[0] == "fixtures": + return 1 if run_fixtures(Path(argv[1])) else 0 + if len(argv) == 3 and argv[0] == "transition": + old = tomllib.loads(Path(argv[1]).read_text(encoding="utf-8")) + new = tomllib.loads(Path(argv[2]).read_text(encoding="utf-8")) + print(sorted(transition(old, new))) + return 0 + print(__doc__) + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tests/spec_gate.py b/tests/spec_gate.py new file mode 100644 index 00000000..c83a2549 --- /dev/null +++ b/tests/spec_gate.py @@ -0,0 +1,41 @@ +"""A skipped Spec conformance test is a failure (conformance plan section 1). + +Every test of a module named ``test_spec_*.py`` is marked ``spec_conformance``. When such a +test is skipped (``pytest.skip``, ``skipif``, ``importorskip``, a skipping fixture) or +expected to fail (``xfail``), the report is turned into a failure, so a missing package, +file or weight cannot make the release gate pass silently. +""" + +from __future__ import annotations + +import pytest + +MARKER = "spec_conformance" +MODULE_PREFIX = "test_spec_" + + +class SkipIsFailure: + """A pytest plugin: marks the Spec tests and fails the ones that did not run.""" + + def pytest_configure(self, config: pytest.Config) -> None: + """Register the marker.""" + config.addinivalue_line("markers", f"{MARKER}: a Spec test that must never skip") + + def pytest_collection_modifyitems(self, items: list[pytest.Item]) -> None: + """Mark every test of a ``test_spec_*.py`` module.""" + for item in items: + if item.path.name.startswith(MODULE_PREFIX): + item.add_marker(MARKER) + + @pytest.hookimpl(hookwrapper=True) + def pytest_runtest_makereport(self, item: pytest.Item, call: pytest.CallInfo): + """Turn a skipped or xfailed report of a marked test into a failure.""" + outcome = yield + report = outcome.get_result() + if item.get_closest_marker(MARKER) is None or not report.skipped: + return + reason = report.longrepr[2] if isinstance(report.longrepr, tuple) else str(report.longrepr) + if hasattr(report, "wasxfail"): + reason = f"expected to fail: {report.wasxfail}" + report.outcome = "failed" + report.longrepr = f"a Spec conformance test must run, it was skipped: {reason}" diff --git a/tests/spec_gate_plugin.py b/tests/spec_gate_plugin.py new file mode 100644 index 00000000..017f4bcf --- /dev/null +++ b/tests/spec_gate_plugin.py @@ -0,0 +1,9 @@ +"""Registers ``SkipIsFailure`` in a pytest run started with ``-p spec_gate_plugin``.""" + +from __future__ import annotations + +from spec_gate import SkipIsFailure + + +def pytest_configure(config): + config.pluginmanager.register(SkipIsFailure(), "spec-skip-is-failure") diff --git a/tests/spec_pin.py b/tests/spec_pin.py new file mode 100644 index 00000000..8461e00a --- /dev/null +++ b/tests/spec_pin.py @@ -0,0 +1,180 @@ +"""The vendored MorphoBricks-Spec files: where they are, what they must hash to, what they hold. + +Conformance plan sections 1 and 2: the fixtures are pinned by the full Spec commit SHA and the +vendored folder must hash to ``git rev-parse :fixtures``. Nothing here reads a branch or +the network. ``MORPHOBRICKS_SPEC_ROOT`` points the tests at another folder laid out like +``tests/spec_fixtures`` (``tests/test_spec_vendored_missing.py`` uses an empty one to prove +that a missing tree fails the suite instead of passing on defaults). +""" + +from __future__ import annotations + +import hashlib +import os +import re +import tomllib +from dataclasses import dataclass +from pathlib import Path + +VENDORED = Path(__file__).parent / "spec_fixtures" +ROOT_ENV = "MORPHOBRICKS_SPEC_ROOT" +KINDS = ("valid", "invalid", "transitions") +CODE = re.compile(r"\b([EWRA]_[A-Z0-9_]+)\b") +ROW = re.compile(r"\| \[`([vit]\d+[^`]*)`\]\([^)]+\) \|(.*)") + + +class SpecPinError(Exception): + """Raised when the vendored Spec files are missing, unreadable or malformed.""" + + +def spec_root() -> Path: + """Return the folder holding ``fixtures/``, ``docs/`` and ``PIN.toml``.""" + override = os.environ.get(ROOT_ENV) + return Path(override) if override else VENDORED + + +def read_pin(root: Path | None = None) -> dict: + """Return the parsed ``PIN.toml``, or raise ``SpecPinError`` when it is absent.""" + path = (root or spec_root()) / "PIN.toml" + if not path.is_file(): + raise SpecPinError(f"{path} is missing: the Spec fixtures are not vendored here") + return tomllib.loads(path.read_text(encoding="utf-8")) + + +def git_blob_id(data: bytes) -> str: + """Return the git blob-object ID of ``data`` (``git hash-object``).""" + return hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest() + + +def git_tree_id(folder: Path) -> str: + """Return the git tree-object ID of a folder, as ``git write-tree`` would give it. + + Every file counts as mode 100644 (the vendored copy has no executable files, and a + file system without mode bits could not tell). A symbolic link, a missing folder or + anything that is neither a file nor a folder raises ``SpecPinError``. + """ + if not folder.is_dir() or folder.is_symlink(): + raise SpecPinError(f"{folder} is not a folder") + entries = [] + for entry in os.scandir(folder): + name = entry.name.encode("utf-8") + if entry.is_symlink(): + raise SpecPinError(f"{entry.path} is a symbolic link") + if entry.is_dir(): + entries.append((name + b"/", b"40000", name, bytes.fromhex(git_tree_id(Path(entry))))) + elif entry.is_file(): + blob = git_blob_id(Path(entry).read_bytes()) + entries.append((name, b"100644", name, bytes.fromhex(blob))) + else: + raise SpecPinError(f"{entry.path} is neither a file nor a folder") + body = b"".join(mode + b" " + name + b"\0" + sha for _, mode, name, sha in sorted(entries)) + return hashlib.sha1(b"tree %d\0" % len(body) + body).hexdigest() + + +def pin_problems(root: Path | None = None) -> list[str]: + """Return what differs between the vendored files and the pin (empty when they match).""" + root = root or spec_root() + pin = read_pin(root) + problems = [] + if not re.fullmatch(r"[0-9a-f]{40}", str(pin.get("commit", ""))): + problems.append("the pin is not a full 40-digit commit SHA") + tree = git_tree_id(root / "fixtures") + if tree != pin.get("fixtures_tree"): + problems.append(f"fixtures/ hashes to {tree}, the pin says {pin.get('fixtures_tree')}") + for relative, blob in pin.get("files", {}).items(): + path = root / relative + if not path.is_file(): + problems.append(f"{relative} is missing") + elif git_blob_id(path.read_bytes()) != blob: + problems.append(f"{relative} does not hash to its pin {blob}") + return problems + + +def codes_in(text: str) -> frozenset[str]: + """Return every ``E_``/``W_``/``R_``/``A_`` code named in a piece of text.""" + return frozenset(CODE.findall(text)) + + +@dataclass(frozen=True) +class Fixture: + """One fixture file of the Spec: ``valid/``, ``invalid/`` or ``transitions/``.""" + + name: str + kind: str + path: Path + + @property + def data(self) -> bytes: + """Return the raw bytes, never decoded text (``i01_bom`` must reach the reader).""" + return self.path.read_bytes() + + @property + def header(self) -> dict[str, str]: + """Return the ``# Fixture``, ``# Expected`` and ``# Source`` comment lines by name.""" + text = self.data.decode("utf-8", errors="replace").lstrip("") + fields = {} + for line in text.splitlines(): + match = re.fullmatch(r"# (Fixture|Expected|Source)(?::| )\s*(.*)", line) + if match and match.group(1) not in fields: + fields[match.group(1)] = match.group(2) + return fields + + @property + def expected_codes(self) -> frozenset[str]: + """Return the codes of the ``Expected`` header (the whole set, errors or warnings).""" + return codes_in(self.header.get("Expected", "")) + + @property + def expected_warnings(self) -> frozenset[str]: + """Return the warning codes a valid fixture is expected to give.""" + return frozenset(c for c in self.expected_codes if c.startswith("W_")) + + @property + def phase(self) -> int: + """Return the validation phase of an invalid fixture, from the catalogue.""" + return int(catalogue()[self.name][2]) + + +def _number(path: Path) -> int: + match = re.match(r"\D(\d+)", path.stem) + if not match: + raise SpecPinError(f"{path.name} is not named like a fixture") + return int(match.group(1)) + + +def fixtures(kind: str, root: Path | None = None) -> list[Fixture]: + """Return the fixtures of one kind in number order, discovered from the directory. + + Raises + ------ + SpecPinError + When the folder is missing or holds no fixture: a missing tree is a failure, never + an empty parametrisation that passes. + """ + folder = (root or spec_root()) / "fixtures" / kind + found = sorted(folder.glob("*.toml"), key=_number) if folder.is_dir() else [] + if not found: + raise SpecPinError(f"no fixtures in {folder}: the Spec fixtures are not vendored here") + return [Fixture(path.stem, kind, path) for path in found] + + +def catalogue(root: Path | None = None) -> dict[str, list[str]]: + """Return the cells of each table row of ``fixtures/README.md`` by fixture name. + + The cells after the name: for ``valid/`` source, expected, homogeneous, case; for + ``invalid/`` source, codes, phase, case; for ``transitions/`` predecessor, expected, case. + """ + path = (root or spec_root()) / "fixtures" / "README.md" + if not path.is_file(): + raise SpecPinError(f"{path} is missing: the Spec fixtures are not vendored here") + rows = {} + for line in path.read_text(encoding="utf-8").splitlines(): + match = ROW.match(line) + if match: + rows[match.group(1)] = [cell.strip() for cell in match.group(2).split("|")] + return rows + + +def all_fixtures(root: Path | None = None) -> list[Fixture]: + """Return every fixture of every kind.""" + return [item for kind in KINDS for item in fixtures(kind, root)] diff --git a/tests/spec_text.py b/tests/spec_text.py new file mode 100644 index 00000000..47ee700e --- /dev/null +++ b/tests/spec_text.py @@ -0,0 +1,89 @@ +"""Read the tables of the vendored normative text (``docs/experiment-v1.md``). + +The reader's tables of codes, forbidden names, reserved names and fields are compared with +what these functions parse, so the text, not a second copy in a test, is the source. +""" + +from __future__ import annotations + +import re +from functools import cache + +from spec_pin import SpecPinError, spec_root + +BACKTICKED = re.compile(r"`([^`]+)`") +HEADING = re.compile(r"^(#+) (.*)") + + +@cache +def spec_text() -> str: + """Return the vendored ``experiment-v1.md``.""" + path = spec_root() / "docs" / "experiment-v1.md" + if not path.is_file(): + raise SpecPinError(f"{path} is missing: the Spec text is not vendored here") + return path.read_text(encoding="utf-8") + + +def section(prefix: str) -> list[str]: + """Return the lines under the heading starting with ``prefix``, up to the next heading + of the same or a higher level.""" + lines = spec_text().splitlines() + start = next( + ( + i + for i, line in enumerate(lines) + if HEADING.match(line) and line.lstrip("# ").startswith(prefix) + ), + None, + ) + if start is None: + raise SpecPinError(f"the vendored text has no section {prefix!r}") + level = len(HEADING.match(lines[start]).group(1)) + out = [] + for line in lines[start + 1 :]: + match = HEADING.match(line) + if match and len(match.group(1)) <= level: + break + out.append(line) + return out + + +def table_rows(lines: list[str], header_start: str) -> list[list[str]]: + """Return the body rows (cells, stripped) of the first table whose header starts so.""" + out: list[list[str]] = [] + inside = False + for line in lines: + line = line.strip() + if not line.startswith("|"): + if inside: + break + continue + cells = [cell.strip() for cell in line.strip("|").split("|")] + if not inside: + inside = cells[0].startswith(header_start) + continue + if not set(line) <= set("|- :"): + out.append(cells) + return out + + +def spec_codes() -> frozenset[str]: + """Return the ``E_`` and ``W_`` codes of the catalogue of section 6.""" + rows = table_rows(section("6."), "Phase") + return frozenset(BACKTICKED.search(cells[1]).group(1) for cells in rows) + + +def spec_forbidden() -> frozenset[str]: + """Return the forbidden field names and ID keys of section 4.7.""" + names: set[str] = set() + for cells in table_rows(section("4.7"), "Field"): + names.update(BACKTICKED.findall(cells[0])) + return frozenset(names) + + +def spec_reserved() -> frozenset[str]: + """Return the reserved list of section 3.2 (channel IDs and mask IDs).""" + names: set[str] = set() + for cells in table_rows(section("3.2"), "Names"): + names.update(BACKTICKED.findall(cells[0])) + return frozenset(names) diff --git a/tests/test_spec_catalogue.py b/tests/test_spec_catalogue.py new file mode 100644 index 00000000..e71f6e11 --- /dev/null +++ b/tests/test_spec_catalogue.py @@ -0,0 +1,73 @@ +"""The fixture catalogue (conformance plan section 2, "Header lines"). + +Every fixture is discovered from the directory, so a new fixture cannot be forgotten: it has +the ``Fixture``, ``Expected`` and ``Source`` header comments, the catalogue +(``fixtures/README.md``) lists it with the same codes, and the catalogue lists no file that +does not exist. +""" + +from __future__ import annotations + +import re + +import pytest + +from spec_pin import KINDS, all_fixtures, catalogue, codes_in, fixtures, spec_root + +ALL = all_fixtures() + + +def _heading_count(kind: str) -> int: + readme = (spec_root() / "fixtures" / "README.md").read_text(encoding="utf-8") + title = {"valid": "Valid", "invalid": "Invalid", "transitions": "Transition"}[kind] + return int(re.search(rf"^## {title} fixtures \((\d+)\)", readme, re.MULTILINE).group(1)) + + +@pytest.mark.parametrize("kind", KINDS) +def test_the_catalogue_lists_every_file_and_nothing_else(kind): + on_disk = {item.name for item in fixtures(kind)} + listed = {name for name in catalogue() if name[0] == kind[0]} + assert on_disk == listed + assert len(on_disk) == _heading_count(kind) + + +def test_fixture_numbers_are_unique_per_kind(): + for kind in KINDS: + numbers = [re.match(r"\D(\d+)", item.name).group(1) for item in fixtures(kind)] + assert len(numbers) == len(set(numbers)) + + +@pytest.mark.parametrize("item", ALL, ids=lambda item: f"{item.kind}/{item.name}") +def test_fixture_has_its_header_lines(item): + header = item.header + assert set(header) == {"Fixture", "Expected", "Source"} + assert header["Fixture"] == item.name + assert header["Expected"] and header["Source"] + + +@pytest.mark.parametrize("item", ALL, ids=lambda item: f"{item.kind}/{item.name}") +def test_header_and_catalogue_agree_on_the_expected_codes(item): + row = catalogue()[item.name] + if item.kind == "valid": + assert item.header["Expected"].startswith("PASS") + assert item.expected_codes == codes_in(row[1]) + elif item.kind == "invalid": + assert item.expected_codes == codes_in(row[1]) + assert item.expected_codes and all(code.startswith("E_") for code in item.expected_codes) + assert row[2] in {"1", "2", "3", "4", "5"} + else: + assert item.expected_codes == {c for c in codes_in(row[1]) if c.startswith("R_")} + + +def test_the_catalogue_names_each_file_by_its_link(): + readme = (spec_root() / "fixtures" / "README.md").read_text(encoding="utf-8") + for item in ALL: + assert f"({item.kind}/{item.name}.toml)" in readme + + +def test_the_encoding_fixtures_are_given_as_bytes(): + # i01 and i02 only reach the encoding check when a reader is given raw bytes. + by_name = {item.name: item for item in ALL} + assert by_name["i01_bom"].data.startswith(b"\xef\xbb\xbf") + with pytest.raises(UnicodeDecodeError): + by_name["i02_invalid_utf8"].data.decode("utf-8") diff --git a/tests/test_spec_gate.py b/tests/test_spec_gate.py new file mode 100644 index 00000000..d6331a66 --- /dev/null +++ b/tests/test_spec_gate.py @@ -0,0 +1,62 @@ +"""A skipped Spec conformance test is a failure (conformance plan section 1).""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +TESTS = Path(__file__).resolve().parent + +SKIPPING = """ +import pytest + +def test_skips(): + pytest.skip("not run") + +@pytest.mark.skipif(True, reason="never") +def test_skipif(): + pass + +@pytest.mark.xfail(reason="later") +def test_xfail(): + assert False + +def test_importorskip(): + pytest.importorskip("a_module_that_is_not_installed") + +def test_passes(): + pass +""" + + +def _pytest(folder: Path) -> tuple[int, str]: + result = subprocess.run( + [sys.executable, "-m", "pytest", "-p", "no:cacheprovider", "-p", "spec_gate_plugin", "-q"], + cwd=folder, + capture_output=True, + text=True, + env={**os.environ, "PYTHONPATH": str(TESTS)}, + check=False, + ) + return result.returncode, result.stdout + + +def test_a_skipped_spec_test_is_reported_as_failed(tmp_path): + (tmp_path / "test_spec_demo.py").write_text(SKIPPING, encoding="utf-8") + code, out = _pytest(tmp_path) + assert code == 1 + # A skip at call time is a failure; a skipif at setup is an error: both fail the run. + refused = [line for line in out.splitlines() if line.startswith(("FAILED", "ERROR"))] + assert len(refused) == 4 + assert out.count("a Spec conformance test must run") >= 4 + assert " 1 passed" in out + + +def test_other_tests_may_still_skip(tmp_path): + (tmp_path / "test_other.py").write_text(SKIPPING, encoding="utf-8") + code, out = _pytest(tmp_path) + assert "FAILED" not in out and "ERROR" not in out + assert "1 passed" in out and "3 skipped" in out and "1 xfailed" in out + assert code == 0 diff --git a/tests/test_spec_no_fallback.py b/tests/test_spec_no_fallback.py new file mode 100644 index 00000000..faaac63b --- /dev/null +++ b/tests/test_spec_no_fallback.py @@ -0,0 +1,202 @@ +"""No second copy of the rules (conformance plan sections 1 and 2, "No fallback rules"). + +The reader has one table of the rules (``description_rules.py``), compared with the vendored +text by its own tests. This scan fails on any other table of codes, enumerations or names in +the package, and on the markers of a lenient, older or default-version path. It runs on +source code as written, so a copy cannot hide behind a test that never imports it. +""" + +from __future__ import annotations + +import ast +import re +from pathlib import Path + +import pytest + +from spec_text import spec_forbidden, spec_reserved + +PACKAGE = Path(__file__).resolve().parents[1] / "src" / "morphobricks_preprocessing" +RULES_MODULE = "description_rules.py" +CODE = re.compile(r"[EWRA]_[A-Z0-9_]+") +MARKERS = ("legacy", "lenient", "fallback", "best effort", "best_effort", "older_version") +VERSION_KEYS = {"schema", "schema_version"} + + +def reader_modules() -> list[Path]: + """Return the modules of the experiment-description reader.""" + return sorted(PACKAGE.glob("description*.py")) + + +def other_modules() -> list[Path]: + """Return the reader's modules except the one table of the rules.""" + return [path for path in reader_modules() if path.name != RULES_MODULE] + + +def _docstrings(tree: ast.AST) -> set[int]: + owners = (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef) + found = set() + for node in ast.walk(tree): + if isinstance(node, owners) and node.body and isinstance(node.body[0], ast.Expr): + value = node.body[0].value + if isinstance(value, ast.Constant) and isinstance(value.value, str): + found.add(id(value)) + return found + + +def _strings(node: ast.AST) -> list[str]: + if isinstance(node, ast.Dict): + elements = [key for key in node.keys if key is not None] + else: + elements = list(node.elts) + return [e.value for e in elements if isinstance(e, ast.Constant) and isinstance(e.value, str)] + + +def code_tables(source: str) -> list[int]: + """Return the lines of collections holding three or more distinct codes.""" + lines = [] + for node in ast.walk(ast.parse(source)): + if isinstance(node, (ast.Set, ast.List, ast.Tuple, ast.Dict)): + codes = {s for s in _strings(node) if CODE.fullmatch(s)} + if len(codes) >= 3: + lines.append(node.lineno) + return lines + + +def name_tables(source: str) -> list[int]: + """Return the lines of collections copying the forbidden or reserved names of the text.""" + forbidden, reserved = spec_forbidden(), spec_reserved() + lines = [] + for node in ast.walk(ast.parse(source)): + if isinstance(node, (ast.Set, ast.List, ast.Tuple, ast.Dict)): + names = set(_strings(node)) + if len(names & forbidden) >= 4 or len(names & reserved) >= 4: + lines.append(node.lineno) + return lines + + +def code_literals(source: str) -> list[int]: + """Return the lines of string literals that are a code (outside docstrings).""" + tree = ast.parse(source) + skip = _docstrings(tree) + return [ + node.lineno + for node in ast.walk(tree) + if isinstance(node, ast.Constant) + and isinstance(node.value, str) + and id(node) not in skip + and CODE.fullmatch(node.value) + ] + + +def fallback_markers(source: str) -> list[str]: + """Return what marks a lenient, older or default-version path (outside docstrings).""" + tree = ast.parse(source) + skip = _docstrings(tree) + found = [] + for node in ast.walk(tree): + words = [] + if isinstance(node, ast.Constant) and isinstance(node.value, str): + words = [] if id(node) in skip else [node.value] + elif isinstance(node, (ast.Name, ast.arg, ast.FunctionDef, ast.ClassDef)): + words = [getattr(node, "id", None) or getattr(node, "arg", None) or node.name] + elif isinstance(node, ast.Attribute): + words = [node.attr] + found += [ + f"{marker} (line {node.lineno})" + for w in words + for marker in MARKERS + if marker in w.lower() + ] + if _default_version(node): + found.append("a default for the schema or its version") + return found + + +def _default_version(node: ast.AST) -> bool: + """Return True for ``x.get("schema_version", default)`` and a ``schema_version=`` default.""" + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): + first = node.args[0] if node.args else None + named = isinstance(first, ast.Constant) and first.value in VERSION_KEYS + return node.func.attr in {"get", "setdefault", "pop"} and named and len(node.args) > 1 + if isinstance(node, ast.arguments): + positional = node.args[len(node.args) - len(node.defaults) :] + keyword = [ + a for a, default in zip(node.kwonlyargs, node.kw_defaults, strict=True) if default + ] + return any(a.arg in VERSION_KEYS for a in positional + keyword) + return False + + +def test_no_table_of_codes_outside_the_rules_module(): + found = {} + for path in sorted(PACKAGE.rglob("*.py")): + if path.name != RULES_MODULE: + lines = code_tables(path.read_text(encoding="utf-8")) + if lines: + found[str(path.relative_to(PACKAGE))] = lines + assert found == {} + + +def test_the_reader_has_no_copy_of_the_names_or_codes(): + found = {} + for path in other_modules(): + source = path.read_text(encoding="utf-8") + lines = name_tables(source) + code_literals(source) + if lines: + found[path.name] = lines + assert found == {} + + +def test_the_reader_has_no_lenient_older_or_default_path(): + found = {} + for path in reader_modules(): + markers = fallback_markers(path.read_text(encoding="utf-8")) + if markers: + found[path.name] = markers + assert found == {} + + +# The scan itself: each kind of copy it exists to catch is caught, a clean module is not. +COPIES = { + "a table of codes": 'CODES = {"E_TYPE", "E_ENUM", "E_EMPTY", "W_UNUSED_CHANNEL"}\n', + "a table of codes as dict keys": 'X = {"E_TYPE": 1, "E_ENUM": 2, "E_EMPTY": 3}\n', +} +NAMES = { + "the forbidden names": 'F = ("axes", "shared", "plane_scope", "pixel_size", "files")\n', + "the reserved list": 'R = ["whole", "cell", "nucleus", "ring", "patch_id"]\n', +} +LITERAL = 'def f(r):\n r.e("E_TYPE")\n' +MARKED = { + "a lenient mode": "def read(data, lenient=False):\n return data\n", + "a legacy table": 'LEGACY_FIELDS = ("a",)\n', + "a fallback name": "def fallback_rules():\n return 1\n", + "a version default": 'def f(doc):\n return doc.get("schema_version", 1)\n', + "a version default argument": "def f(data, schema_version=1):\n return data\n", +} + + +@pytest.mark.parametrize("source", COPIES.values(), ids=COPIES) +def test_scan_catches_a_table_of_codes(source): + assert code_tables(source) + + +@pytest.mark.parametrize("source", NAMES.values(), ids=NAMES) +def test_scan_catches_a_copy_of_the_forbidden_or_reserved_names(source): + assert name_tables(source) + + +def test_scan_catches_a_code_literal_but_not_a_docstring(): + assert code_literals(LITERAL) + assert not code_literals('"""Gives E_TYPE."""\n\ndef f():\n """Gives E_ENUM."""\n') + + +@pytest.mark.parametrize("source", MARKED.values(), ids=MARKED) +def test_scan_catches_a_lenient_or_default_path(source): + assert fallback_markers(source) + + +def test_scan_passes_a_clean_module(): + clean = '"""Never a fallback."""\n\ndef read(doc):\n return doc["schema_version"]\n' + assert fallback_markers(clean) == [] + assert code_tables(clean) == [] and name_tables(clean) == [] and code_literals(clean) == [] diff --git a/tests/test_spec_pin.py b/tests/test_spec_pin.py new file mode 100644 index 00000000..ffa31fa1 --- /dev/null +++ b/tests/test_spec_pin.py @@ -0,0 +1,148 @@ +"""Pin integrity (conformance plan sections 1 and 2): the vendored Spec files hash to the pin. + +The fixtures are pinned by the full Spec commit SHA. The vendored ``fixtures/`` folder must +hash to ``git rev-parse :fixtures`` (git's tree algorithm), and the two other vendored +files to their blob IDs, so one changed, added or missing byte fails a test and a local edit +of a fixture cannot make a reader pass. +""" + +from __future__ import annotations + +import re +import shutil +from pathlib import Path + +import pytest + +from spec_pin import ( + SpecPinError, + git_blob_id, + git_tree_id, + pin_problems, + read_pin, + spec_root, +) + + +def test_vendored_files_hash_to_the_pin(): + assert pin_problems() == [] + + +def test_the_pin_is_a_full_commit_sha_never_a_branch(): + pin = read_pin() + assert re.fullmatch(r"[0-9a-f]{40}", pin["commit"]) + assert re.fullmatch(r"[0-9a-f]{40}", pin["fixtures_tree"]) + assert set(pin["files"]) == {"docs/experiment-v1.md", "tools/audit/refval.py"} + assert all(re.fullmatch(r"[0-9a-f]{40}", blob) for blob in pin["files"].values()) + + +def test_the_pinned_tree_is_the_folder_that_is_vendored(): + # The expected value is the pin's own: this fails when fixtures/ is not what it names. + assert git_tree_id(spec_root() / "fixtures") == read_pin()["fixtures_tree"] + + +def test_git_blob_and_tree_ids_equal_git_s(): + # Known answers computed with git itself (git hash-object, git write-tree). + assert git_blob_id(b"hello\n") == "ce013625030ba8dba906f756967f9e9ca394464a" + + +def test_git_tree_id_orders_a_folder_as_its_name_and_a_slash(tmp_path): + # `a.b` sorts before the folder `a` (compared as `a/`), `a-b` before both: the order that + # makes a hand-rolled tree hash differ from git's when it sorts by plain name. + (tmp_path / "b").mkdir() + (tmp_path / "a").mkdir() + (tmp_path / "a.txt").write_bytes(b"hello\n") + (tmp_path / "b" / "c").write_bytes(b"x") + (tmp_path / "a.b").write_bytes(b"y") + (tmp_path / "a-b").write_bytes(b"z") + (tmp_path / "a" / "f").write_bytes(b"w") + assert git_tree_id(tmp_path) == "fec6a54403e1c2a94724c1aab9f127c686f6dbb0" + + +def _copy(tmp_path: Path) -> Path: + root = tmp_path / "spec" + shutil.copytree(spec_root(), root) + return root + + +def _flip_byte(path: Path) -> None: + data = bytearray(path.read_bytes()) + data[len(data) // 2] ^= 0x01 + path.write_bytes(bytes(data)) + + +def _flip_fixture(root: Path) -> None: + _flip_byte(root / "fixtures" / "valid" / "v01_single_control.toml") + + +def _flip_catalogue(root: Path) -> None: + _flip_byte(root / "fixtures" / "README.md") + + +def _add_fixture(root: Path) -> None: + (root / "fixtures" / "valid" / "v99_added.toml").write_bytes(b"# added\n") + + +def _remove_fixture(root: Path) -> None: + (root / "fixtures" / "invalid" / "i05_duplicate_field.toml").unlink() + + +def _rename_fixture(root: Path) -> None: + folder = root / "fixtures" / "valid" + (folder / "v02_multiple_controls.toml").rename(folder / "v02_renamed.toml") + + +def _crlf_fixture(root: Path) -> None: + path = root / "fixtures" / "valid" / "v03_paired_different_folder_names.toml" + path.write_bytes(path.read_bytes().replace(b"\n", b"\r\n")) + + +def _flip_text(root: Path) -> None: + _flip_byte(root / "docs" / "experiment-v1.md") + + +def _flip_oracle(root: Path) -> None: + _flip_byte(root / "tools" / "audit" / "refval.py") + + +def _remove_oracle(root: Path) -> None: + (root / "tools" / "audit" / "refval.py").unlink() + + +MUTATIONS = [ + _flip_fixture, + _flip_catalogue, + _add_fixture, + _remove_fixture, + _rename_fixture, + _crlf_fixture, + _flip_text, + _flip_oracle, + _remove_oracle, +] + + +@pytest.mark.parametrize("mutate", MUTATIONS, ids=lambda f: f.__name__.lstrip("_")) +def test_one_changed_added_or_missing_byte_fails_the_pin(tmp_path, mutate): + root = _copy(tmp_path) + assert pin_problems(root) == [] + mutate(root) + assert pin_problems(root) != [] + + +def test_a_symbolic_link_in_the_fixtures_is_refused(tmp_path): + root = _copy(tmp_path) + link = root / "fixtures" / "valid" / "v98_link.toml" + try: + link.symlink_to(root / "fixtures" / "valid" / "v01_single_control.toml") + except OSError as error: # a file system without links: nothing to check + pytest.fail(f"cannot create a link to test the refusal: {error}") + with pytest.raises(SpecPinError, match="symbolic link"): + pin_problems(root) + + +def test_a_pin_that_is_not_a_full_sha_is_reported(tmp_path): + root = _copy(tmp_path) + pin = root / "PIN.toml" + pin.write_text(pin.read_text(encoding="utf-8").replace(read_pin()["commit"], "main")) + assert any("40-digit" in problem for problem in pin_problems(root)) diff --git a/tests/test_spec_vendored_missing.py b/tests/test_spec_vendored_missing.py new file mode 100644 index 00000000..345d9bf7 --- /dev/null +++ b/tests/test_spec_vendored_missing.py @@ -0,0 +1,55 @@ +"""Removing the vendored tree fails the suite; it never passes on defaults (plan section 2). + +The pin and catalogue tests run again in a child pytest whose Spec root is an empty folder, a +folder that does not exist, and a copy without ``fixtures/``. Each run must fail (or stop at +collection) and never report a pass or a skip for a test that needs the files. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +from spec_pin import ROOT_ENV, spec_root + +TESTS = Path(__file__).resolve().parent +CHILD = ["test_spec_pin.py", "test_spec_catalogue.py"] + + +def _run(root: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "-m", "pytest", "-p", "no:cacheprovider", "-q", *CHILD], + cwd=TESTS, + capture_output=True, + text=True, + env={**os.environ, ROOT_ENV: str(root)}, + check=False, + ) + + +def _without_fixtures(tmp_path: Path) -> Path: + root = tmp_path / "copy" + shutil.copytree(spec_root(), root) + shutil.rmtree(root / "fixtures") + return root + + +@pytest.mark.parametrize("variant", ["empty", "absent", "no_fixtures"]) +def test_a_missing_vendored_tree_fails_the_suite(tmp_path, variant): + if variant == "empty": + root = tmp_path / "empty" + root.mkdir() + elif variant == "absent": + root = tmp_path / "does_not_exist" + else: + root = _without_fixtures(tmp_path) + result = _run(root) + output = result.stdout.lower() + assert result.returncode != 0, result.stdout + assert "error" in output or "failed" in output, result.stdout + assert "skipped" not in output diff --git a/tools/check.py b/tools/check.py index f123f1ec..a2c6deb3 100644 --- a/tools/check.py +++ b/tools/check.py @@ -52,9 +52,13 @@ def config() -> dict: def _python_files() -> list[Path]: - """Return the repository's checked Python files (guard fixtures are deliberately bad).""" + """Return the repository's checked Python files. + + The guard fixtures are deliberately bad and ``spec_fixtures`` holds byte-exact copies of + Spec files (the reference validator among them). + """ files = [path for folder in LINTED for path in sorted((ROOT / folder).rglob("*.py"))] - return [path for path in files if "guard_fixtures" not in path.parts] + return [path for path in files if not {"guard_fixtures", "spec_fixtures"} & set(path.parts)] def _module_name(path: Path, package_dir: Path) -> str: From e9a7086e8379b940d02bd47a5ff7b26e08a4f095 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 09:04:06 +0000 Subject: [PATCH 2/3] Add the strict description reader, phases 1-3 (Stage 1, PR 2) New pure modules for the experiment-description reader (Spec experiment-v1 sections 2-4, 6): - description_rules: the one table of the rules (codes, field tables, forbidden names, the enumerations, the supported version set), compared with the vendored text by a test. - description_report: DescriptionIssue (code, table path, value) and DescriptionReport. - description_read: phase 1 (raw bytes: UTF-8 without BOM, TOML 1.0, a key declared twice in any spelling as E_DUPLICATE_KEY), phase 2 (schema and version, exact types) and the driver read_structure(bytes), which stops after the first failing phase. - description_structure: phase 3 (closed field sets, forbidden fields and ID keys, required fields, exact types, non-empty tables and arrays, dates, ranges, enumerations, N35). Every invalid/ fixture of phases 1-3 (65) gives exactly its Expected codes from its phase; all other fixtures pass these phases. The vendored reference validator is the oracle: the reader agrees with it on every fixture and on ~17,000 one-step mutations of nine valid ones. Not used by any run, notebook or CLI command yet. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Scma6W3LC11s3qjXHZuGDm --- AGENTS.md | 7 + CHANGELOG.md | 6 + pyproject.toml | 4 + .../description_read.py | 108 ++++++++ .../description_report.py | 77 ++++++ .../description_rules.py | 130 ++++++++++ .../description_structure.py | 233 ++++++++++++++++++ tests/spec_oracle.py | 135 ++++++++++ tests/spec_text.py | 30 +-- tests/test_spec_no_fallback.py | 8 + tests/test_spec_reader_phases_1_3.py | 163 ++++++++++++ tests/test_spec_refval_crosscheck.py | 73 ++++++ tests/test_spec_rules_table.py | 68 +++++ 13 files changed, 1028 insertions(+), 14 deletions(-) create mode 100644 src/morphobricks_preprocessing/description_read.py create mode 100644 src/morphobricks_preprocessing/description_report.py create mode 100644 src/morphobricks_preprocessing/description_rules.py create mode 100644 src/morphobricks_preprocessing/description_structure.py create mode 100644 tests/spec_oracle.py create mode 100644 tests/test_spec_reader_phases_1_3.py create mode 100644 tests/test_spec_refval_crosscheck.py create mode 100644 tests/test_spec_rules_table.py diff --git a/AGENTS.md b/AGENTS.md index 54e3d1bc..71fb5390 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,6 +92,13 @@ src/morphobricks_preprocessing/ read_confirmed_design/ConfirmedDesign (the strict reader other bricks use, §16) design_values.py the design file's header check (no column twice or unnamed, #83), booleans, counts, dates and DesignFormatError (pure; re-exported by design.py) + description_rules.py the one table of the experiment-description rules (Stage 1, Spec + experiment-v1): codes, field tables, forbidden names, enumerations; compared + with the vendored text by tests/test_spec_rules_table.py + description_report.py DescriptionIssue (code, table path, value), DescriptionReport + description_read.py the reference reader's bytes -> phases: decode (1), header (2), the + driver (read_structure: phases 1-3) + description_structure.py phase 3: closed fields, forbidden names, exact types, ranges, enums image_type.py image type (P4): FLUORESCENCE/RGB, review_image_type (suggestion from TiffMetadata.rgb_samples and the confirmed roles, whether RGB is possible via the brick's check_rgb), image_type_issues (rgb_not_possible) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6fc59fc..3e711ef5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ are in [docs/plan.md](docs/plan.md) §12. Upgrading from v0.3: see the ## Unreleased +- **Experiment-description reader, phases 1-3 (Stage 1, PR 2).** New modules + `description_rules`, `description_report`, `description_read` and `description_structure`: + `read_structure(data: bytes)` runs the strict phases 1 to 3 of Spec experiment-v1 (bytes and + encoding, duplicate keys, header and version, structure, forbidden fields) and reports every + error of the first failing phase with its code, table path and value. Not used by any run, + notebook or CLI command yet. - **Spec fixtures vendored (Stage 1, PR 1).** `tests/spec_fixtures/` holds the MorphoBricks-Spec conformance fixtures at a pinned commit, with a git tree-hash pin test, a gate that makes a skipped `tests/test_spec_*.py` test a failure, and the "no fallback diff --git a/pyproject.toml b/pyproject.toml index 54c0fb46..804b2dc2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -172,6 +172,10 @@ baseline = "tools/code_rules_baseline.json" "morphobricks_preprocessing.dataset" = "pure" "morphobricks_preprocessing.design" = "io" "morphobricks_preprocessing.design_values" = "pure" +"morphobricks_preprocessing.description_read" = "pure" +"morphobricks_preprocessing.description_report" = "pure" +"morphobricks_preprocessing.description_rules" = "pure" +"morphobricks_preprocessing.description_structure" = "pure" "morphobricks_preprocessing.design_edit" = "pure" "morphobricks_preprocessing.discovery" = "io" "morphobricks_preprocessing.links" = "io" diff --git a/src/morphobricks_preprocessing/description_read.py b/src/morphobricks_preprocessing/description_read.py new file mode 100644 index 00000000..ce56566b --- /dev/null +++ b/src/morphobricks_preprocessing/description_read.py @@ -0,0 +1,108 @@ +"""The reference reader of ``morphobricks.toml``: bytes in, phases and issues out. + +Experiment-v1 sections 2 and 6. The reader is given raw bytes (never decoded text, so a byte +order mark or invalid UTF-8 reaches the encoding check) and runs the validation phases in +order, stopping after the first phase that reports an error. Nothing is converted, trimmed, +normalized or defaulted, and the only rules are those of ``description_rules`` and the phase +modules. This module holds phases 1 (decode) and 2 (header) and the driver. +""" + +from __future__ import annotations + +import hashlib +import re +import tomllib +from collections.abc import Callable, Mapping + +from .description_report import DescriptionReport, Issues +from .description_rules import SCHEMA_ID, SUPPORTED_VERSIONS, Code +from .description_structure import check_structure + +BYTE_ORDER_MARK = b"\xef\xbb\xbf" +DUPLICATE = re.compile( + r"Cannot (?:declare .* twice|overwrite a value|mutate immutable namespace|redefine namespace)" + r"|Duplicate inline table key" +) +"""The ``tomllib`` messages for a key or table declared twice, in any spelling TOML treats as +the same key (bare or quoted, table, dotted or inline). The Python 3.11 and 3.13 parsers share +them; ``tests/test_spec_reader_phases_1_3.py`` covers each form.""" + + +def decode(data: bytes) -> tuple[dict[str, object] | None, Issues]: + """Phase 1: the bytes must be UTF-8 without a byte-order mark and valid TOML 1.0. + + Returns + ------- + tuple + The parsed document (None when phase 1 failed) and the phase's issues: + ``E_FILE_ENCODING``, ``E_DUPLICATE_KEY`` or ``E_TOML_SYNTAX``. + """ + out = Issues() + if data.startswith(BYTE_ORDER_MARK): + out.add(Code.E_FILE_ENCODING, "", "byte-order mark") + return None, out + try: + text = data.decode("utf-8") + except UnicodeDecodeError as error: + out.add(Code.E_FILE_ENCODING, "", str(error)) + return None, out + try: + return tomllib.loads(text), out + except tomllib.TOMLDecodeError as error: + message = str(error) + out.add( + Code.E_DUPLICATE_KEY if DUPLICATE.match(message) else Code.E_TOML_SYNTAX, + "", + message, + ) + except RecursionError: + out.add(Code.E_TOML_SYNTAX, "", "nested too deeply to read") + return None, out + + +def check_header(doc: Mapping[str, object]) -> Issues: + """Phase 2: ``schema`` and ``schema_version``, exact types, the supported version only. + + A missing, unsupported or mistyped version stops the validation: no field of a version the + reader does not implement is interpreted (experiment-v1 section 6). + """ + out = Issues() + if "schema" not in doc: + out.add(Code.E_SCHEMA_ID, "schema") + elif type(doc["schema"]) is not str: + out.add(Code.E_TYPE, "schema", doc["schema"]) + elif doc["schema"] != SCHEMA_ID: + out.add(Code.E_SCHEMA_ID, "schema", doc["schema"]) + if "schema_version" not in doc: + out.add(Code.E_SCHEMA_VERSION_MISSING, "schema_version") + elif type(doc["schema_version"]) is not int: + out.add(Code.E_TYPE, "schema_version", doc["schema_version"]) + elif doc["schema_version"] not in SUPPORTED_VERSIONS: + out.add(Code.E_SCHEMA_VERSION_UNSUPPORTED, "schema_version", doc["schema_version"]) + return out + + +PHASES: tuple[tuple[int, Callable[[Mapping[str, object]], Issues]], ...] = ( + (2, check_header), + (3, check_structure), +) +"""The document phases after decoding, in order, as (number, check).""" + + +def read_structure(data: bytes) -> DescriptionReport: + """Run phases 1 to 3 on the raw bytes of a file and report the first failing phase. + + This is the structural reading only: a file it accepts is not yet a validated + description (identities and semantics are phases 4 and 5). + """ + if not isinstance(data, bytes): + raise TypeError("the reader is given the raw bytes of the file, not decoded text") + digest = hashlib.sha256(data).hexdigest() + doc, issues = decode(data) + if doc is None: + return DescriptionReport(digest, 1, tuple(issues.items), (), None) + for phase, check in PHASES: + issues = check(doc) + if issues: + return DescriptionReport(digest, phase, tuple(issues.items), (), doc) + return DescriptionReport(digest, PHASES[-1][0], (), (), doc) diff --git a/src/morphobricks_preprocessing/description_report.py b/src/morphobricks_preprocessing/description_report.py new file mode 100644 index 00000000..047bc173 --- /dev/null +++ b/src/morphobricks_preprocessing/description_report.py @@ -0,0 +1,77 @@ +"""What the experiment-description reader reports: issues with code, table path and value. + +Experiment-v1 section 6: every error of the first failing phase is reported, each with its +code, the table path (for example ``repeats.Day1.members.CTRL.technical_repeats.single. +channels[2]``) and the offending value. Warnings are reported only for a file without errors. +""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from dataclasses import dataclass + +from .description_rules import Code + +BARE_KEY = re.compile(r"[A-Za-z0-9_-]+") + + +def key_path(base: str, key: str) -> str: + """Return the table path of ``key`` under ``base``, quoting a key TOML would quote.""" + name = key if BARE_KEY.fullmatch(key) else f'"{key}"' + return f"{base}.{name}" if base else name + + +@dataclass(frozen=True) +class DescriptionIssue: + """One error or warning: its code, table path and the offending value (None if absent).""" + + code: Code + path: str + value: object = None + + +class Issues: + """The issues of one validation phase, collected in the order they are found.""" + + def __init__(self) -> None: + self.items: list[DescriptionIssue] = [] + + def add(self, code: Code, path: str, value: object = None) -> None: + """Record one issue.""" + self.items.append(DescriptionIssue(code, path, value)) + + def __bool__(self) -> bool: + return bool(self.items) + + +@dataclass(frozen=True) +class DescriptionReport: + """The outcome of reading one file. + + ``phase`` is the phase that reported ``errors`` (1 to 5), or, for a file without errors, + the last phase that ran. ``document`` is the parsed TOML (never modified, never + normalized) once phase 1 succeeded, else None. ``sha256`` is the digest of the exact + bytes read: provenance, not a cache key (experiment-v1 section 2.6). + """ + + sha256: str + phase: int + errors: tuple[DescriptionIssue, ...] + warnings: tuple[DescriptionIssue, ...] + document: Mapping[str, object] | None + + @property + def ok(self) -> bool: + """Return True when the file has no error.""" + return not self.errors + + @property + def codes(self) -> frozenset[Code]: + """Return the set of error codes (what conformance compares).""" + return frozenset(issue.code for issue in self.errors) + + @property + def warning_codes(self) -> frozenset[Code]: + """Return the set of warning codes.""" + return frozenset(issue.code for issue in self.warnings) diff --git a/src/morphobricks_preprocessing/description_rules.py b/src/morphobricks_preprocessing/description_rules.py new file mode 100644 index 00000000..e3ed81c0 --- /dev/null +++ b/src/morphobricks_preprocessing/description_rules.py @@ -0,0 +1,130 @@ +"""The one table of the experiment-description rules (experiment-v1 sections 3, 4 and 6). + +Codes, field tables, the forbidden names and the fixed enumerations live here and nowhere +else in the reader (conformance plan section 1: no second copy of the rules). +``tests/test_spec_rules_table.py`` compares every table with the vendored normative text; +a rule that changes in the Spec changes here in the same pull request, with its fixtures. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + + +class Code(StrEnum): + """Every error and warning code of experiment-v1 section 6. + + ``R_*`` and ``A_*`` are not the reader's: preprocessing's publication and the analysis + layer own them. + """ + + E_FILE_ENCODING = "E_FILE_ENCODING" + E_TOML_SYNTAX = "E_TOML_SYNTAX" + E_DUPLICATE_KEY = "E_DUPLICATE_KEY" + E_SCHEMA_ID = "E_SCHEMA_ID" + E_SCHEMA_VERSION_MISSING = "E_SCHEMA_VERSION_MISSING" + E_SCHEMA_VERSION_UNSUPPORTED = "E_SCHEMA_VERSION_UNSUPPORTED" + E_TYPE = "E_TYPE" + E_UNKNOWN_FIELD = "E_UNKNOWN_FIELD" + E_FORBIDDEN_FIELD = "E_FORBIDDEN_FIELD" + E_MISSING_FIELD = "E_MISSING_FIELD" + E_EMPTY = "E_EMPTY" + E_TEXT_EMPTY = "E_TEXT_EMPTY" + E_ENUM = "E_ENUM" + E_DATE_FORMAT = "E_DATE_FORMAT" + E_VALUE_RANGE = "E_VALUE_RANGE" + E_REPEAT_UNCONFIRMED = "E_REPEAT_UNCONFIRMED" + E_ID_TEXT = "E_ID_TEXT" + E_ID_SYNTAX = "E_ID_SYNTAX" + E_ID_RESERVED = "E_ID_RESERVED" + E_ID_NOT_NFC = "E_ID_NOT_NFC" + E_ID_CASE_COLLISION = "E_ID_CASE_COLLISION" + E_SINGLE_MISUSE = "E_SINGLE_MISUSE" + E_UNKNOWN_CONDITION = "E_UNKNOWN_CONDITION" + E_UNKNOWN_CHANNEL = "E_UNKNOWN_CHANNEL" + E_DUPLICATE_CHANNEL_IN_UNIT = "E_DUPLICATE_CHANNEL_IN_UNIT" + E_DUPLICATE_VALUE = "E_DUPLICATE_VALUE" + E_RGB_UNIT_CHANNELS = "E_RGB_UNIT_CHANNELS" + E_RGB_TOKEN_MISUSE = "E_RGB_TOKEN_MISUSE" + E_RGB_ALIAS = "E_RGB_ALIAS" + E_DISPLAY_NAME_COLLISION = "E_DISPLAY_NAME_COLLISION" + E_USED_ID_MISSING = "E_USED_ID_MISSING" + E_SOURCE_FOLDER_REUSED = "E_SOURCE_FOLDER_REUSED" + E_SOURCE_FOLDER_CONFLICT = "E_SOURCE_FOLDER_CONFLICT" + E_CONDITION_WITHOUT_MEMBERS = "E_CONDITION_WITHOUT_MEMBERS" + E_SOURCE_REPEAT_REUSED = "E_SOURCE_REPEAT_REUSED" + E_REPEAT_ID_SHADOWS_SOURCE = "E_REPEAT_ID_SHADOWS_SOURCE" + E_MASK_SOURCE_TYPE_REUSED = "E_MASK_SOURCE_TYPE_REUSED" + E_REFERENCE_UNKNOWN = "E_REFERENCE_UNKNOWN" + E_REFERENCE_SELF = "E_REFERENCE_SELF" + E_REFERENCE_CYCLE = "E_REFERENCE_CYCLE" + W_UNUSED_CHANNEL = "W_UNUSED_CHANNEL" + W_REPEAT_MISSING_CONDITIONS = "W_REPEAT_MISSING_CONDITIONS" + W_REFERENCE_UNPAIRED = "W_REFERENCE_UNPAIRED" + + +@dataclass(frozen=True) +class TableSpec: + """The closed set of fields of one table, in the order of the Spec's table. + + ``required`` names the ones that must be present. + """ + + fields: tuple[str, ...] + required: frozenset[str] + + +SCHEMA_ID = "morphobricks-experiment" +SUPPORTED_VERSIONS = frozenset({1}) +"""The schema versions this reader implements: a constant, checked against nothing fetched +(experiment-v1 section 7).""" + +REPRESENTATIONS = ("instances", "binary") +IMAGE_TYPES = ("rgb",) + +TOP = TableSpec( + ("schema", "schema_version", "conditions", "channels", "repeats", "masks", "used_ids"), + frozenset({"schema", "schema_version", "conditions", "channels", "repeats", "used_ids"}), +) +CONDITION = TableSpec( + ("source_folder", "display_name", "reference", "target", "treatment_key"), + frozenset({"source_folder"}), +) +CHANNEL = TableSpec(("display_name", "header_names"), frozenset()) +REPEAT = TableSpec( + ("display_name", "confirmed", "confirmed_on", "members"), + frozenset({"confirmed", "confirmed_on", "members"}), +) +MEMBER = TableSpec( + ("source_repeat", "image_count", "technical_repeats"), + frozenset({"source_repeat", "image_count", "technical_repeats"}), +) +UNIT = TableSpec( + ("source_folder", "display_name", "channels", "image_type"), + frozenset({"source_folder", "channels"}), +) +MASK = TableSpec( + ("source_type", "representation", "source_channels", "display_name"), + frozenset({"source_type", "representation"}), +) +USED_IDS = TableSpec( + ("conditions", "channels", "repeats", "technical_repeats"), + frozenset({"conditions", "channels", "repeats", "technical_repeats"}), +) + +FORBIDDEN = frozenset( + { + "axes", "shared", "plane_scope", "z", "projection", + "pixel_size", "calibration", + "fovs", "files", "paths", "overrides", "exclude", "fingerprints", + "bricks", "routes", "features", "normalization", "statistics", "contrasts", + "binary_mask_policy", "mask_conversion", + "control", "roles", "experiment", + "source_index", "channel_count", + "retired_repeats", "replaced_by", + "labchronicle", "provenance", "source_database", + } +) # fmt: skip +"""Field names and ID keys refused as ``E_FORBIDDEN_FIELD``, in any table, compared exactly +and case-sensitively (experiment-v1 section 4.7).""" diff --git a/src/morphobricks_preprocessing/description_structure.py b/src/morphobricks_preprocessing/description_structure.py new file mode 100644 index 00000000..d71b88b4 --- /dev/null +++ b/src/morphobricks_preprocessing/description_structure.py @@ -0,0 +1,233 @@ +"""Validation phase 3, structure (experiment-v1 sections 4 and 6). + +Closed field sets, forbidden names, required fields, exact TOML types (nothing is converted), +non-empty tables and arrays, dates, integer ranges, the enumerations and the N35 confirmation. +It reads the parsed document only, never a folder, and reports every error of the phase. +""" + +from __future__ import annotations + +import re +from collections.abc import Callable, Iterator, Mapping +from datetime import date + +from .description_report import Issues, key_path +from .description_rules import ( + CHANNEL, + CONDITION, + FORBIDDEN, + IMAGE_TYPES, + MASK, + MEMBER, + REPEAT, + REPRESENTATIONS, + TOP, + UNIT, + USED_IDS, + Code, + TableSpec, +) + +DATE = re.compile(r"\d{4}-\d{2}-\d{2}") + + +def _is_text(value: object) -> bool: + return type(value) is str + + +def _is_table(value: object) -> bool: + return type(value) is dict + + +def _fields(table: Mapping[str, object], spec: TableSpec, path: str, out: Issues) -> None: + """Report forbidden, unknown and missing fields of one table.""" + for key in table: + if key in FORBIDDEN: + out.add(Code.E_FORBIDDEN_FIELD, key_path(path, key), key) + elif key not in spec.fields: + out.add(Code.E_UNKNOWN_FIELD, key_path(path, key), key) + for key in spec.fields: + if key in spec.required and key not in table: + out.add(Code.E_MISSING_FIELD, key_path(path, key)) + + +def _text(table: Mapping[str, object], key: str, path: str, out: Issues, *, filled: bool) -> None: + """Check a text field; with ``filled`` it may not be empty or whitespace only.""" + if key not in table: + return + value = table[key] + if not _is_text(value): + out.add(Code.E_TYPE, key_path(path, key), value) + elif filled and value.strip() == "": + out.add(Code.E_TEXT_EMPTY, key_path(path, key), value) + + +def _text_array( + table: Mapping[str, object], key: str, path: str, out: Issues, *, filled: bool, minimum: int +) -> None: + """Check an array of text; ``filled`` refuses empty items, ``minimum`` entries needed.""" + if key not in table: + return + value = table[key] + here = key_path(path, key) + if type(value) is not list: + out.add(Code.E_TYPE, here, value) + return + if minimum and len(value) < minimum: + out.add(Code.E_EMPTY, here, value) + for index, item in enumerate(value): + if not _is_text(item): + out.add(Code.E_TYPE, f"{here}[{index}]", item) + elif filled and item.strip() == "": + out.add(Code.E_TEXT_EMPTY, f"{here}[{index}]", item) + + +def _id_tables( + parent: Mapping[str, object], key: str, path: str, out: Issues, *, minimum: int +) -> Iterator[tuple[str, Mapping[str, object], str]]: + """Yield (ID, table, path) of an ID-keyed table; a forbidden ID key is reported, skipped. + + A missing table is reported by the parent's field check, not here. + """ + if key not in parent: + return + here = key_path(path, key) + tables = parent[key] + if not _is_table(tables): + out.add(Code.E_TYPE, here, tables) + return + if minimum and not tables: + out.add(Code.E_EMPTY, here, tables) + for identifier, table in tables.items(): + if identifier in FORBIDDEN: + out.add(Code.E_FORBIDDEN_FIELD, key_path(here, identifier), identifier) + elif not _is_table(table): + out.add(Code.E_TYPE, key_path(here, identifier), table) + else: + yield identifier, table, key_path(here, identifier) + + +def _condition(table: Mapping[str, object], path: str, out: Issues) -> None: + _fields(table, CONDITION, path, out) + _text(table, "source_folder", path, out, filled=False) + _text(table, "display_name", path, out, filled=True) + _text(table, "reference", path, out, filled=False) + _text(table, "target", path, out, filled=True) + _text(table, "treatment_key", path, out, filled=True) + + +def _channel(table: Mapping[str, object], path: str, out: Issues) -> None: + _fields(table, CHANNEL, path, out) + _text(table, "display_name", path, out, filled=True) + _text_array(table, "header_names", path, out, filled=True, minimum=0) + + +def _confirmation(table: Mapping[str, object], path: str, out: Issues) -> None: + """Check ``confirmed`` (a boolean, true in a published file) and ``confirmed_on``.""" + if "confirmed" in table: + value = table["confirmed"] + if type(value) is not bool: + out.add(Code.E_TYPE, f"{path}.confirmed", value) + elif not value: + out.add(Code.E_REPEAT_UNCONFIRMED, f"{path}.confirmed", value) + if "confirmed_on" in table: + value = table["confirmed_on"] + if not _is_text(value): + out.add(Code.E_TYPE, f"{path}.confirmed_on", value) + elif not _real_date(value): + out.add(Code.E_DATE_FORMAT, f"{path}.confirmed_on", value) + + +def _real_date(value: str) -> bool: + """Return True for a real calendar day written ``YYYY-MM-DD`` (no other spelling).""" + if not DATE.fullmatch(value): + return False + try: + date.fromisoformat(value) + except ValueError: + return False + return True + + +def _unit(table: Mapping[str, object], path: str, out: Issues) -> None: + _fields(table, UNIT, path, out) + _text(table, "source_folder", path, out, filled=False) + _text(table, "display_name", path, out, filled=True) + _text_array(table, "channels", path, out, filled=False, minimum=1) + if "image_type" in table: + value = table["image_type"] + if not _is_text(value): + out.add(Code.E_TYPE, f"{path}.image_type", value) + elif value not in IMAGE_TYPES: + out.add(Code.E_ENUM, f"{path}.image_type", value) + + +def _member(table: Mapping[str, object], path: str, out: Issues) -> None: + _fields(table, MEMBER, path, out) + _text(table, "source_repeat", path, out, filled=False) + if "image_count" in table: + count = table["image_count"] + if type(count) is not int: + out.add(Code.E_TYPE, f"{path}.image_count", count) + elif count < 1: + out.add(Code.E_VALUE_RANGE, f"{path}.image_count", count) + for _, unit, unit_path in _id_tables(table, "technical_repeats", path, out, minimum=1): + _unit(unit, unit_path, out) + + +def _repeat(table: Mapping[str, object], path: str, out: Issues) -> None: + _fields(table, REPEAT, path, out) + _text(table, "display_name", path, out, filled=True) + _confirmation(table, path, out) + for _, member, member_path in _id_tables(table, "members", path, out, minimum=1): + _member(member, member_path, out) + + +def _mask(table: Mapping[str, object], path: str, out: Issues) -> None: + _fields(table, MASK, path, out) + _text(table, "source_type", path, out, filled=False) + _text(table, "display_name", path, out, filled=True) + _text_array(table, "source_channels", path, out, filled=False, minimum=0) + if "representation" in table: + value = table["representation"] + if not _is_text(value): + out.add(Code.E_TYPE, f"{path}.representation", value) + elif value not in REPRESENTATIONS: + out.add(Code.E_ENUM, f"{path}.representation", value) + + +def _used_ids(doc: Mapping[str, object], out: Issues) -> None: + if "used_ids" not in doc: + return + table = doc["used_ids"] + if not _is_table(table): + out.add(Code.E_TYPE, "used_ids", table) + return + _fields(table, USED_IDS, "used_ids", out) + for key in USED_IDS.fields: + _text_array(table, key, "used_ids", out, filled=False, minimum=1) + + +SECTIONS: tuple[tuple[str, int, Callable[[Mapping[str, object], str, Issues], None]], ...] = ( + ("conditions", 1, _condition), + ("channels", 1, _channel), + ("repeats", 1, _repeat), + ("masks", 0, _mask), +) + + +def check_structure(doc: Mapping[str, object]) -> Issues: + """Return every phase-3 error of a parsed document. + + Parameters + ---------- + doc + The parsed TOML, with the header already checked. + """ + out = Issues() + _fields(doc, TOP, "", out) + for key, minimum, check in SECTIONS: + for _, table, path in _id_tables(doc, key, "", out, minimum=minimum): + check(table, path, out) + _used_ids(doc, out) + return out diff --git a/tests/spec_oracle.py b/tests/spec_oracle.py new file mode 100644 index 00000000..1cccf027 --- /dev/null +++ b/tests/spec_oracle.py @@ -0,0 +1,135 @@ +"""The Spec's reference validator as a test oracle, and mutations of parsed documents. + +``tools/audit/refval.py`` is an audit tool that no brick may import: it is loaded here from the +pin-verified vendored copy, by path, only to compare its verdicts with the reader's. The +reader never calls it (``tests/test_spec_no_fallback.py`` scans the package). +""" + +from __future__ import annotations + +import copy +import datetime +import importlib.util +from collections.abc import Iterator +from functools import cache +from pathlib import Path +from types import ModuleType + +from spec_pin import spec_root + + +@cache +def refval() -> ModuleType: + """Return the vendored reference validator module (loaded by path).""" + path = Path(spec_root()) / "tools" / "audit" / "refval.py" + spec = importlib.util.spec_from_file_location("spec_refval", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def normal(path: str) -> str: + """Return a table path without the leading dot the oracle gives top-level fields.""" + return path.lstrip(".") + + +def oracle_verdict(data: bytes) -> tuple[int, frozenset[str], frozenset[str], frozenset[str]]: + """Return (phase, error codes, error paths, warning codes) the oracle gives for bytes.""" + report, _ = refval().validate(data) + paths = frozenset(normal(path) for _, path, _ in report.errors) + return report.phase, frozenset(report.codes), paths, frozenset(report.warning_codes) + + +def oracle_document_verdict(doc: dict, phases: tuple[int, ...]) -> tuple[int, set, set]: + """Run the oracle's document phases (2 to 5) on a parsed document. + + Returns the first failing phase (the last one given when none fails), its error codes and + their normalized paths. + """ + module = refval() + functions = {2: module.phase2, 3: module.phase3, 4: module.phase4, 5: module.phase5} + report = module.Report() + phase = 0 + for phase in phases: + functions[phase](doc, report) + if report.errors: + break + return phase, set(report.codes), {normal(path) for _, path, _ in report.errors} + + +REPLACEMENTS = ( + 1, + 0, + -1, + 1.5, + True, + False, + "", + " ", + "x", + "NA", + " x", + "Single", + "single", + "ctrl", + "Café", + ["x"], + [], + {}, + {"x": {}}, + datetime.date(2026, 1, 1), + datetime.datetime(2026, 1, 1, 12, 0), + datetime.time(12, 0), +) + + +def _paths(node: object, trail: tuple = ()) -> Iterator[tuple]: + """Yield the trail (keys and indexes) of every node below the root.""" + if isinstance(node, dict): + for key, child in node.items(): + yield (*trail, key) + yield from _paths(child, (*trail, key)) + elif isinstance(node, list): + for index, child in enumerate(node): + yield (*trail, index) + yield from _paths(child, (*trail, index)) + + +def _parent(doc: object, trail: tuple) -> object: + for step in trail[:-1]: + doc = doc[step] + return doc + + +def mutations(doc: dict) -> Iterator[tuple[str, dict]]: + """Yield (label, mutated copy) of a document, one change at a time, in a fixed order. + + Every node is deleted, replaced by each of ``REPLACEMENTS`` and, for a table key, renamed + to a forbidden, an unknown and a case-changed name; a text array entry is duplicated. + """ + for trail in _paths(doc): + label = ".".join(str(step) for step in trail) + copied = copy.deepcopy(doc) + parent, last = _parent(copied, trail), trail[-1] + value = parent[last] + if isinstance(parent, dict): + del parent[last] + yield f"delete {label}", copied + for index, replacement in enumerate(REPLACEMENTS): + if replacement == value and type(replacement) is type(value): + continue + copied = copy.deepcopy(doc) + _parent(copied, trail)[last] = copy.deepcopy(replacement) + yield f"set {label} #{index}", copied + if isinstance(parent, dict): + for name in ("files", "colour", str(last).swapcase(), str(last) + "x"): + if name == last or name in parent: + continue + copied = copy.deepcopy(doc) + holder = _parent(copied, trail) + holder[name] = holder.pop(last) + yield f"rename {label} to {name}", copied + if isinstance(value, list) and value: + copied = copy.deepcopy(doc) + _parent(copied, trail)[last] = [*value, value[0]] + yield f"duplicate first of {label}", copied diff --git a/tests/spec_text.py b/tests/spec_text.py index 47ee700e..9260eb6e 100644 --- a/tests/spec_text.py +++ b/tests/spec_text.py @@ -48,23 +48,25 @@ def section(prefix: str) -> list[str]: return out -def table_rows(lines: list[str], header_start: str) -> list[list[str]]: - """Return the body rows (cells, stripped) of the first table whose header starts so.""" - out: list[list[str]] = [] - inside = False - for line in lines: - line = line.strip() +def table_rows(lines: list[str], header_start: str, nth: int = 0) -> list[list[str]]: + """Return the body rows (cells, stripped) of the nth table whose header starts so.""" + tables: list[list[list[str]]] = [] + current: list[list[str]] | None = None + for raw in lines: + line = raw.strip() if not line.startswith("|"): - if inside: - break + current = None continue cells = [cell.strip() for cell in line.strip("|").split("|")] - if not inside: - inside = cells[0].startswith(header_start) - continue - if not set(line) <= set("|- :"): - out.append(cells) - return out + if current is None: + current = [] + if cells[0].startswith(header_start): + tables.append(current) + elif not set(line) <= set("|- :"): + current.append(cells) + if nth >= len(tables): + raise SpecPinError(f"the section has no table {nth} headed {header_start!r}") + return tables[nth] def spec_codes() -> frozenset[str]: diff --git a/tests/test_spec_no_fallback.py b/tests/test_spec_no_fallback.py index faaac63b..89c2afe7 100644 --- a/tests/test_spec_no_fallback.py +++ b/tests/test_spec_no_fallback.py @@ -128,6 +128,14 @@ def _default_version(node: ast.AST) -> bool: return False +def test_the_scan_sees_the_reader_modules(): + # Not vacuous: the modules it exists to scan are there (and the rules module is the one + # table the other scans leave out). + names = {path.name for path in reader_modules()} + assert {"description_rules.py", "description_read.py", "description_structure.py"} <= names + assert RULES_MODULE in names and RULES_MODULE not in {p.name for p in other_modules()} + + def test_no_table_of_codes_outside_the_rules_module(): found = {} for path in sorted(PACKAGE.rglob("*.py")): diff --git a/tests/test_spec_reader_phases_1_3.py b/tests/test_spec_reader_phases_1_3.py new file mode 100644 index 00000000..f19d2edd --- /dev/null +++ b/tests/test_spec_reader_phases_1_3.py @@ -0,0 +1,163 @@ +"""Reader phases 1 to 3 on the Spec fixtures (conformance plan section 2). + +Every ``invalid/`` fixture of phases 1 to 3 is refused with exactly the codes of its +``Expected`` header, from its phase, each error with a table path and the offending value; +every other fixture passes these phases. The reader is given the raw bytes. +""" + +from __future__ import annotations + +import socket + +import pytest + +from morphobricks_preprocessing.description_read import read_structure +from morphobricks_preprocessing.description_rules import Code +from spec_pin import fixtures + +INVALID = fixtures("invalid") +EARLY = [item for item in INVALID if item.phase <= 3] +LATER = [item for item in INVALID if item.phase > 3] +PASSING = fixtures("valid") + fixtures("transitions") +ABSENT = {Code.E_MISSING_FIELD, Code.E_SCHEMA_ID, Code.E_SCHEMA_VERSION_MISSING} + + +def _name(item): + return item.name + + +def test_the_first_three_phases_have_their_fixtures(): + assert len(EARLY) == 65 + assert {item.phase for item in EARLY} == {1, 2, 3} + + +@pytest.mark.parametrize("item", EARLY, ids=_name) +def test_an_invalid_fixture_gives_exactly_its_codes_from_its_phase(item): + report = read_structure(item.data) + assert {code.value for code in report.codes} == item.expected_codes + assert report.phase == item.phase + assert report.warnings == () + + +@pytest.mark.parametrize("item", EARLY, ids=_name) +def test_each_error_carries_a_path_and_the_offending_value(item): + for issue in read_structure(item.data).errors: + assert issue.path + # A value is absent only where the field is: a missing field or header entry. + assert issue.value is not None or issue.code in ABSENT + + +@pytest.mark.parametrize("item", LATER + PASSING, ids=_name) +def test_a_file_without_a_phase_1_to_3_fault_passes_these_phases(item): + report = read_structure(item.data) + assert report.errors == () + assert report.document is not None + + +def test_a_phase_1_failure_has_no_document_and_later_phases_never_run(): + by_name = {item.name: item for item in INVALID} + for name in ( + "i01_bom", + "i02_invalid_utf8", + "i03_duplicate_condition_table", + "i07_unquoted_bareword", + ): + report = read_structure(by_name[name].data) + assert report.phase == 1 and report.document is None + + +def test_the_reader_is_given_bytes_never_decoded_text(): + with pytest.raises(TypeError, match="raw bytes"): + read_structure("schema = 'morphobricks-experiment'\n") + + +def test_the_digest_is_that_of_the_exact_bytes(): + import hashlib + + item = fixtures("valid")[0] + assert read_structure(item.data).sha256 == hashlib.sha256(item.data).hexdigest() + + +def test_the_version_check_needs_no_network_and_has_no_default(monkeypatch): + def refuse(*args, **kwargs): + raise AssertionError("a socket was opened") + + monkeypatch.setattr(socket, "socket", refuse) + monkeypatch.setattr(socket, "create_connection", refuse) + by_name = {item.name: item for item in INVALID} + for name, code in ( + ("i09_schema_version_2", Code.E_SCHEMA_VERSION_UNSUPPORTED), + ("i10_schema_version_0", Code.E_SCHEMA_VERSION_UNSUPPORTED), + ("i11_schema_version_missing", Code.E_SCHEMA_VERSION_MISSING), + ): + report = read_structure(by_name[name].data) + assert report.codes == {code} + # Nothing of a version the reader does not implement is interpreted. + assert report.phase == 2 + + +def test_exact_types_a_boolean_is_not_the_integer_one(): + by_name = {item.name: item for item in INVALID} + report = read_structure(by_name["i14_schema_version_bool"].data) + assert report.codes == {Code.E_TYPE} + assert report.errors[0].value is True + + +DUPLICATES = { + "table twice": b"[a]\nx = 1\n[a]\ny = 2\n", + "bare then quoted": b'[a]\nx = 1\n["a"]\ny = 2\n', + "field twice": b"x = 1\nx = 2\n", + "dotted twice": b"a.b = 1\na.b = 2\n", + "table then dotted": b"[a.b]\nx = 1\n[a]\nb.y = 2\n", + "key then dotted": b"a = 1\na.b = 2\n", + "inline then dotted": b"i = {a = 1}\ni.b = 2\n", + "inline then table": b"i = {a = 1}\n[i.b]\nc = 1\n", + "inline key twice": b"i = {a = 1, a = 2}\n", + "static array extended": b"a = [1]\n[[a]]\n", + "value then table": b"x = 1\n[x]\ny = 1\n", + "table then array of tables": b"[a]\n[[a]]\n", + "array of tables then table": b"[[a]]\n[a]\n", +} +SYNTAX = { + "bareword": b"x = NO\n", + "leading zero": b"x = 012\n", + "unclosed string": b'x = "abc\n', + "unclosed array": b"x = [1, 2\n", + "missing value": b"x =\n", + "two on a line": b"x = 1 y = 2\n", + "bad table": b"[a\n", + "bad escape": b'x = "\\q"\n', + "bad date": b"x = 2026-13-45\n", +} +BASE = b'schema = "morphobricks-experiment"\nschema_version = 1\n' + + +@pytest.mark.parametrize("name", DUPLICATES) +def test_a_key_declared_twice_in_any_spelling_is_a_duplicate_key(name): + report = read_structure(DUPLICATES[name]) + assert report.codes == {Code.E_DUPLICATE_KEY} + assert report.phase == 1 + + +@pytest.mark.parametrize("name", SYNTAX) +def test_other_decode_errors_are_toml_syntax(name): + report = read_structure(SYNTAX[name]) + assert report.codes == {Code.E_TOML_SYNTAX} + + +@pytest.mark.parametrize( + ("data", "code"), + [ + (b"\xef\xbb\xbf" + BASE, Code.E_FILE_ENCODING), + (BASE + b'x = "\xff"\n', Code.E_FILE_ENCODING), + (BASE + b'x = "\xc0\x80"\n', Code.E_FILE_ENCODING), + (BASE + b'x = "\xed\xa0\x80"\n', Code.E_FILE_ENCODING), + (b"\xff\xfe" + BASE, Code.E_FILE_ENCODING), + (BASE.decode().encode("utf-16"), Code.E_FILE_ENCODING), + (b"[" * 100_000, Code.E_TOML_SYNTAX), + (b"x = " + b"[" * 100_000 + b"]" * 100_000, Code.E_TOML_SYNTAX), + ], + ids=["bom", "invalid byte", "overlong", "surrogate", "utf16 bom", "utf16", "brackets", "deep"], +) +def test_encoding_and_hostile_bytes_are_refused_with_a_code_not_a_traceback(data, code): + assert read_structure(data).codes == {code} diff --git a/tests/test_spec_refval_crosscheck.py b/tests/test_spec_refval_crosscheck.py new file mode 100644 index 00000000..1c569913 --- /dev/null +++ b/tests/test_spec_refval_crosscheck.py @@ -0,0 +1,73 @@ +"""The reader against the Spec's reference validator, the oracle (conformance plan section 2). + +The reference validator (``tools/audit/refval.py``, vendored at the pin, loaded by path) is the +oracle: the reader must give the same phase, the same set of codes and the same table paths on +every fixture, and on every one-step mutation of the valid fixtures (a field deleted, retyped, +renamed or duplicated, one at a time). A difference is a bug in the reader or, if the text is +ambiguous, a point to take to the Spec: it is never settled by editing the oracle. +""" + +from __future__ import annotations + +import pytest + +from morphobricks_preprocessing.description_read import PHASES, read_structure +from spec_oracle import mutations, normal, oracle_document_verdict, oracle_verdict, refval +from spec_pin import all_fixtures, fixtures + +ALL = all_fixtures() +MUTATED = ("v01", "v03", "v07", "v11", "v13", "v28", "v33", "v34", "v35") +DOCUMENT_PHASES = tuple(number for number, _ in PHASES) + + +def _reader_verdict(data: bytes): + report = read_structure(data) + paths = frozenset(normal(issue.path) for issue in report.errors) + return report.phase, frozenset(code.value for code in report.codes), paths + + +@pytest.mark.parametrize("item", ALL, ids=lambda item: f"{item.kind}/{item.name}") +def test_the_reader_and_the_oracle_agree_on_a_fixture(item): + phase, codes, paths, _ = oracle_verdict(item.data) + mine_phase, mine_codes, mine_paths = _reader_verdict(item.data) + if not codes: + assert mine_codes == frozenset() + elif phase <= max(DOCUMENT_PHASES): + assert (mine_phase, mine_codes, mine_paths) == (phase, codes, paths) + else: + # The oracle went on to a phase the reader does not implement yet: nothing earlier. + assert mine_codes == frozenset() + + +def test_the_oracle_is_the_pinned_one(): + # Loaded from the vendored copy, whose hash test_spec_pin.py holds to the pin. + assert refval().__file__.endswith("tools/audit/refval.py") + + +def _mine(doc: dict): + for phase, check in PHASES: + issues = check(doc) + if issues: + codes = {issue.code.value for issue in issues.items} + return phase, codes, {normal(issue.path) for issue in issues.items} + return PHASES[-1][0], set(), set() + + +def _documents(): + by_number = {item.name.split("_")[0]: item for item in fixtures("valid")} + for number in MUTATED: + report = read_structure(by_number[number].data) + yield number, dict(report.document) + + +@pytest.mark.parametrize( + ("number", "doc"), list(_documents()), ids=lambda v: v if isinstance(v, str) else "" +) +def test_the_reader_and_the_oracle_agree_on_every_one_step_mutation(number, doc): + checked = 0 + for label, mutated in mutations(doc): + expected = oracle_document_verdict(mutated, DOCUMENT_PHASES) + got = _mine(mutated) + assert got == expected, f"{number}: {label}" + checked += 1 + assert checked > 500 diff --git a/tests/test_spec_rules_table.py b/tests/test_spec_rules_table.py new file mode 100644 index 00000000..d585ef4f --- /dev/null +++ b/tests/test_spec_rules_table.py @@ -0,0 +1,68 @@ +"""The reader's one table of rules equals the vendored normative text (conformance plan 1). + +The tables of ``description_rules`` are not a second source: each one is compared here with the +table of ``docs/experiment-v1.md`` it states, so a change of the text without the reader (or +the reverse) fails. +""" + +from __future__ import annotations + +import re + +import pytest + +from morphobricks_preprocessing import description_rules as rules +from morphobricks_preprocessing.description_rules import Code, TableSpec +from spec_text import BACKTICKED, section, spec_codes, spec_forbidden, table_rows + + +def _fields(heading: str, nth: int = 0) -> tuple[tuple[str, ...], frozenset[str]]: + """Return the keys in order, and the required ones, of the nth ``Key`` table of a section.""" + rows = [r for r in table_rows(section(heading), "Key", nth) if BACKTICKED.match(r[0])] + keys = tuple(BACKTICKED.match(r[0]).group(1) for r in rows) + required = frozenset(BACKTICKED.match(r[0]).group(1) for r in rows if r[2].startswith("yes")) + return keys, required + + +TABLES = { + "4.1": (0, rules.TOP), + "4.2": (0, rules.CONDITION), + "4.3": (0, rules.CHANNEL), + "4.4": (0, rules.REPEAT), + "4.4 members": (1, rules.MEMBER), + "4.4 units": (2, rules.UNIT), + "4.5": (0, rules.MASK), + "4.9": (0, rules.USED_IDS), +} + + +@pytest.mark.parametrize("name", TABLES) +def test_a_field_table_equals_the_text(name): + nth, table = TABLES[name] + assert isinstance(table, TableSpec) + keys, required = _fields(name.split()[0], nth) + assert table.fields == keys + assert table.required == required + + +def test_the_codes_are_the_catalogue_of_section_6(): + assert {code.value for code in Code} == spec_codes() + assert all(code.name == code.value for code in Code) + + +def test_the_forbidden_names_are_section_4_7(): + assert rules.FORBIDDEN == spec_forbidden() + + +def test_the_enumerations_and_the_schema_header_are_the_text(): + mask_rule = next(r for r in table_rows(section("4.5"), "Key") if r[0] == "`representation`") + assert set(rules.REPRESENTATIONS) == {"instances", "binary"} + assert all(f"`{value}`" in mask_rule[3] for value in rules.REPRESENTATIONS) + units = table_rows(section("4.4"), "Key", 2) + unit_rule = next(r for r in units if r[0] == "`image_type`") + assert rules.IMAGE_TYPES == ("rgb",) + assert re.search(r"Only the value `rgb`", unit_rule[3]) + top = {r[0]: r for r in table_rows(section("4.1"), "Key")} + assert f"`{rules.SCHEMA_ID}`" in top["`schema`"][3] + assert rules.SUPPORTED_VERSIONS == {1} + assert "Exactly `1`" in top["`schema_version`"][3] From db26fb6d18355c7371ace64a0f570013c59546a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 09:17:22 +0000 Subject: [PATCH 3/3] Compare the oracle path with forward slashes on every OS The pin test compared refval's __file__ with a POSIX suffix, which fails on Windows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Scma6W3LC11s3qjXHZuGDm --- tests/test_spec_refval_crosscheck.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_spec_refval_crosscheck.py b/tests/test_spec_refval_crosscheck.py index 1c569913..65ac8a86 100644 --- a/tests/test_spec_refval_crosscheck.py +++ b/tests/test_spec_refval_crosscheck.py @@ -9,6 +9,8 @@ from __future__ import annotations +from pathlib import Path + import pytest from morphobricks_preprocessing.description_read import PHASES, read_structure @@ -41,7 +43,7 @@ def test_the_reader_and_the_oracle_agree_on_a_fixture(item): def test_the_oracle_is_the_pinned_one(): # Loaded from the vendored copy, whose hash test_spec_pin.py holds to the pin. - assert refval().__file__.endswith("tools/audit/refval.py") + assert Path(refval().__file__).as_posix().endswith("tools/audit/refval.py") def _mine(doc: dict):