diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8d6188f9..d6761735 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -674,7 +674,7 @@ jobs: JSON.stringify(point.sourceBinding.exclusions) !== JSON.stringify(pointProof.source_binding_exclusions) || point.sourceBinding.closureSha256 !== pointProof.source_closure_sha256 || - pointProof.source_negative_controls !== 33 || + pointProof.source_negative_controls !== 42 || pointAlgebra?.method !== "exact-sparse-integer-polynomial-identities-plus-positive-denominator-order-lemma-v1" || pointAlgebra?.wolfram_language_cross_check?.query_sha256 !== diff --git a/crates/labcolors-core/contracts/point-support-reference-surplus-q55-bps-proof-v1.json b/crates/labcolors-core/contracts/point-support-reference-surplus-q55-bps-proof-v1.json index 8462366a..9dd07d2a 100644 --- a/crates/labcolors-core/contracts/point-support-reference-surplus-q55-bps-proof-v1.json +++ b/crates/labcolors-core/contracts/point-support-reference-surplus-q55-bps-proof-v1.json @@ -1 +1 @@ -{"artifact_id":"wcag22-srgb8-luminance-q55-v1","basis_point_proof":{"checks":30,"drop_all_semantics":"zero required surplus; current must still meet the anchor","drop_domain_inclusive":[0,10000],"nonpositive_baseline_semantics":"zero required surplus; current must meet the anchor"},"bound_id":"point-support-reference-surplus-q55-bps-v1","certified_claim":"for every successfully evaluated enabled stability cell, decision is Retained iff current_lower_surplus >= (10000-drop_bps)/10000 * max(baseline_lower_surplus,0); the declared anchor remains a separate hard floor","comparator_proof":{"algorithm":"euclidean-continued-fraction-ordering-v1","dense_denominator_inclusive":[1,31],"dense_numerator_inclusive":[0,31],"dense_small_cases":984064,"invariant":"equal integer parts; reciprocal proper fractions reverse order","largest_fibonacci_index":186,"oracle":"unbounded-integer-cross-product","random_cases":250000,"random_corpus_sha256":"97c4af7b452b31a4ab92645f70c17acb38bf57ca55484e32ad9d7d79d97a333d","random_seed":210583930,"termination":"each nonterminal denominator becomes a strictly smaller remainder","u128_adversarial_cases":190},"declared_operation_law":"q55-lower-reference-distance-explicit-anchor-bps-retention-v1","excluded_claim":"does not certify retention against the unknown exact baseline surplus, renderer equivalence outside encoded-sRGB8 source-over, or a successful result when evaluation fails","integer_replay_envelope":{"assumption":"every Q55 luminance upper <= scale + 3","i128_max":170141183460469231731687303715884105727,"offset_cleared_denominator_max":756604737398243388,"positive_baseline_numerator_max":1188950301625811064,"rational_denominator_max":1513209474796486776,"required_denominator_max":15132094747964867760000,"required_numerator_max":11889503016258110640000,"signed_anchor_abs_coarse_max":5296233161787703716,"u128_max":340282366920938463463374607431768211455,"u64_max":18446744073709551615},"profile_id":"srgb8-q55-retained-reference-surplus-bps-v1","proof_id":"point-support-reference-surplus-integer-v1","proof_payload_sha256":"76cc2f9916efb337fdc7cb20c444f619c289dc3a3d9a877fdc4087fefe33a12a","q55_dependency":{"artifact_id":"wcag22-srgb8-luminance-q55-v1","artifact_sha256":"7ff239d9052b346f3c50da01ca65ca2330892ed1a3ff30e190797fcef6f03604","maximum_luminance_upper":36028797018963971,"outward_interval_width_bound":3,"proof_id":"wcag22-srgb8-full-domain-q55-v1","proof_payload_sha256":"3c639a7c875046c46b56b51ecdd67d5ecaf14a1134490c88a222e7037b63c0f2","proof_sha256":"ac59cf89503170c789223b91d775213a19d4e571ef930f2ea609fcd51b14defd","q55_scale":36028797018963968},"reference_and_anchor_proof":{"anchor_identity_checks":75,"orientation_law":"distance-magnitude-symmetric-orientation-reported-separately","overlap_lower_distance":"0/1","separated_endpoint_checks":504},"schema_version":2,"site_id":"point-support-retained-reference-surplus-v1","source_binding_exclusions":["whole-crate compilation or compiler/toolchain attestation","binary, package, FFI, renderer, or browser transport attestation","unrelated Lab Colors modules outside the declared point-support semantic cone"],"source_binding_law":"point-support-rust-whole-file-semantic-cone-v2","source_binding_schema_version":2,"source_binding_scope":"exact bytes of the private point-support Rust semantic cone and its two WCAG include_str inputs; comments and cfg(test) text are intentionally significant","source_closure_sha256":"c2825216354b796924560d98e01ae5cebedf324c47e7b26332119c61aded783e","source_files":[{"kind":"compile-time-input","path":"crates/labcolors-core/contracts/wcag22-srgb8-q55-proof-v1.json","sha256":"ac59cf89503170c789223b91d775213a19d4e571ef930f2ea609fcd51b14defd"},{"kind":"compile-time-input","path":"crates/labcolors-core/contracts/wcag22-srgb8-v1.json","sha256":"b4bb7e5f17a99f2c911fdbe3da23a48b049277b796291094950f14680cc3cc7b"},{"kind":"rust-source","path":"crates/labcolors-core/src/appearance.rs","sha256":"5df64a220c86378c66d25f0e0abe2507b636b0ec6cd8217a6235e17809f00f48"},{"kind":"rust-source","path":"crates/labcolors-core/src/composition.rs","sha256":"5218a845b85a27571a710c7c967b2937b94cf4622a3073487a808936ac85468a"},{"kind":"rust-source","path":"crates/labcolors-core/src/constraints/exact.rs","sha256":"45bd09cd8f6f74860a0a3ffeb410ee2c3cf91b4868877abbc949e724b61e1ccd"},{"kind":"rust-source","path":"crates/labcolors-core/src/constraints/mod.rs","sha256":"ad721c625b33a7432de44e5f63459a62c1cbc8ec2492855f783b5440f04978f9"},{"kind":"rust-source","path":"crates/labcolors-core/src/constraints/wcag22.rs","sha256":"985e21abccd1fcb4a3f9624d5947ff3d1af7a1bf26b6b170ea8bbef77d6f1798"},{"kind":"rust-source","path":"crates/labcolors-core/src/hash.rs","sha256":"f97a0fd7d6ad3162f0f1dfb326fccfb7ed40da9a8fa67a5b8a239a1ae2ae49c3"},{"kind":"rust-source","path":"crates/labcolors-core/src/lib.rs","sha256":"95113d8a25fd1577823b8c6342c06272b12ea8711ee5e5a62d034948177c13db"},{"kind":"rust-source","path":"crates/labcolors-core/src/numerics.rs","sha256":"e73a12136494f2ef9aca4e943ab38302c1439f054cecab36a552d35252c164f9"},{"kind":"rust-source","path":"crates/labcolors-core/src/observation.rs","sha256":"208066b255bc406303b18dc2eb6f1720c8203ea6eb37d225f9d8110c2449edc4"},{"kind":"rust-source","path":"crates/labcolors-core/src/point_support.rs","sha256":"56c06b49f4687b3def69c247978da1b0cb45b42c09515a001d196cb1457c93a6"},{"kind":"rust-source","path":"crates/labcolors-core/src/session.rs","sha256":"d4b06651e07cf4f93eccf9866f6e63c35ea72fa7125bdb41e222b19b70dd2f32"},{"kind":"rust-source","path":"crates/labcolors-core/src/srgb8.rs","sha256":"6c95324eb05476f35f75375a9af0b2b4a41b8b2978c46e67d2ce1aea5adde342"},{"kind":"rust-source","path":"crates/labcolors-core/src/wcag22.rs","sha256":"7ba7864eb7e73789bad6c63c64a4dc2dcc08c2da6921375fb9564fca230c2780"},{"kind":"rust-source","path":"crates/labcolors-core/src/wcag22/kernel.rs","sha256":"c97980c1ca2c7ea9cabff9c8d2fb7282773cca180ae15948391c29c9d6196040"},{"kind":"rust-source","path":"crates/labcolors-core/src/wcag22/q55_data.rs","sha256":"af4d23d6b70c45ce6efa839e7dda4bb0a61f6aae43cb805af6fa9b29e6c3bae2"},{"kind":"rust-source","path":"crates/labcolors-core/src/wcag22_evidence.rs","sha256":"3c5a75b07254c6071a64700af208a64987d0f0ea9698eadc54a9e74585ce1f72"}],"source_negative_controls":33,"universal_algebraic_certificate":{"basis_point_scale_instantiation":10000,"domain":"integers; Q55 scale Q>0; anchor L>=D>=0; lighter monotonicity L2>=L1>D>=0; darker monotonicity L>D2>=D1>=0; current/baseline denominators b,q>0; basis-point scale B>0 instantiated as 10000; p>0; a>=0; 0<=drop_bps<=B","identities":["three explicit anchor-surplus formulas after denominator clearing","reference distance is monotone increasing in lighter L","reference distance is monotone decreasing in darker D","positive-baseline retained threshold is p*(B-drop)/(q*B)","a/b >= p*(B-drop)/(q*B) iff a*q*B >= p*(B-drop)*b"],"method":"exact-sparse-integer-polynomial-identities-plus-positive-denominator-order-lemma-v1","nonpositive_baseline_case":"max(baseline,0)=0; retained threshold is exactly zero","symbolic_mutation_controls":{"anchor_coefficients_and_denominator":6,"retained_cross_product":5},"wolfram_language_cross_check":{"query":"FullSimplify[{20 g/d - 0 == 20 g/d, 20 g/d - 2 == (20 g - 2 d)/d, 20 g/d - 7/2 == (40 g - 7 d)/(2 d), Equivalent[a/b >= p (s-x)/(q s), a q s >= p (s-x) b], Max[p/q, 0] (s-x)/s == Piecewise[{{0, p <= 0}}, p (s-x)/(q s)]}, Assumptions -> Element[{a,b,p,q,s,x,g,d}, Integers] && a >= 0 && b > 0 && q > 0 && s > 0 && 0 <= x <= s && d > 0 && g >= 0]","query_sha256":"8cdbb9964583030c8b92498961896cb2a98613f1cb31eb7c54acdf8e16beff10","result":"{True, True, True, True, True}","result_sha256":"13a8f2ee8d0fde335a638e46d7cc8a8427b9a1437c77d22cfcf925bb87fa6303"}},"verifier_sha256":"bc9ceb056a9bd4e93f5c5c5fd575779384027f00985b0075356169a8a11aabf4"} +{"artifact_id":"wcag22-srgb8-luminance-q55-v1","basis_point_proof":{"checks":30,"drop_all_semantics":"zero required surplus; current must still meet the anchor","drop_domain_inclusive":[0,10000],"nonpositive_baseline_semantics":"zero required surplus; current must meet the anchor"},"bound_id":"point-support-reference-surplus-q55-bps-v1","certified_claim":"for every successfully evaluated enabled stability cell, decision is Retained iff current_lower_surplus >= (10000-drop_bps)/10000 * max(baseline_lower_surplus,0); the declared anchor remains a separate hard floor","comparator_proof":{"algorithm":"euclidean-continued-fraction-ordering-v1","dense_denominator_inclusive":[1,31],"dense_numerator_inclusive":[0,31],"dense_small_cases":984064,"invariant":"equal integer parts; reciprocal proper fractions reverse order","largest_fibonacci_index":186,"oracle":"unbounded-integer-cross-product","random_cases":250000,"random_corpus_sha256":"97c4af7b452b31a4ab92645f70c17acb38bf57ca55484e32ad9d7d79d97a333d","random_seed":210583930,"termination":"each nonterminal denominator becomes a strictly smaller remainder","u128_adversarial_cases":190},"declared_operation_law":"q55-lower-reference-distance-explicit-anchor-bps-retention-v1","excluded_claim":"does not certify retention against the unknown exact baseline surplus, renderer equivalence outside encoded-sRGB8 source-over, or a successful result when evaluation fails","integer_replay_envelope":{"assumption":"every Q55 luminance upper <= scale + 3","i128_max":170141183460469231731687303715884105727,"offset_cleared_denominator_max":756604737398243388,"positive_baseline_numerator_max":1188950301625811064,"rational_denominator_max":1513209474796486776,"required_denominator_max":15132094747964867760000,"required_numerator_max":11889503016258110640000,"signed_anchor_abs_coarse_max":5296233161787703716,"u128_max":340282366920938463463374607431768211455,"u64_max":18446744073709551615},"profile_id":"srgb8-q55-retained-reference-surplus-bps-v1","proof_id":"point-support-reference-surplus-integer-v1","proof_payload_sha256":"62871d3b874e4b4601c11db97f5ab73cd4e9781e462330bccda0d9dfdadcb5be","q55_dependency":{"artifact_id":"wcag22-srgb8-luminance-q55-v1","artifact_sha256":"7ff239d9052b346f3c50da01ca65ca2330892ed1a3ff30e190797fcef6f03604","maximum_luminance_upper":36028797018963971,"outward_interval_width_bound":3,"proof_id":"wcag22-srgb8-full-domain-q55-v1","proof_payload_sha256":"3c639a7c875046c46b56b51ecdd67d5ecaf14a1134490c88a222e7037b63c0f2","proof_sha256":"ac59cf89503170c789223b91d775213a19d4e571ef930f2ea609fcd51b14defd","q55_scale":36028797018963968},"reference_and_anchor_proof":{"anchor_identity_checks":75,"orientation_law":"distance-magnitude-symmetric-orientation-reported-separately","overlap_lower_distance":"0/1","separated_endpoint_checks":504},"schema_version":2,"site_id":"point-support-retained-reference-surplus-v1","source_binding_exclusions":["whole-crate compilation or compiler/toolchain attestation","binary, package, FFI, renderer, or browser transport attestation","unrelated Lab Colors modules outside the declared point-support semantic cone"],"source_binding_law":"point-support-rust-whole-file-semantic-cone-v2","source_binding_schema_version":2,"source_binding_scope":"exact bytes of the private point-support Rust semantic cone and its two WCAG include_str inputs; comments and cfg(test) text are intentionally significant","source_closure_sha256":"2dba7f59bd0f8d665b79d3286527cc67a99d1dfe1f7604e6d19be3643e39ed5d","source_files":[{"kind":"compile-time-input","path":"crates/labcolors-core/contracts/wcag22-srgb8-q55-proof-v1.json","sha256":"ac59cf89503170c789223b91d775213a19d4e571ef930f2ea609fcd51b14defd"},{"kind":"compile-time-input","path":"crates/labcolors-core/contracts/wcag22-srgb8-v1.json","sha256":"b4bb7e5f17a99f2c911fdbe3da23a48b049277b796291094950f14680cc3cc7b"},{"kind":"rust-source","path":"crates/labcolors-core/src/appearance.rs","sha256":"57e6feeab15182322c258829d10214c31bb3c93fa58d8d47e37127db204cdc28"},{"kind":"rust-source","path":"crates/labcolors-core/src/composition.rs","sha256":"195a67327a3bd86d7816b634481389930bf68577bb1202fad14c2ea152df8625"},{"kind":"rust-source","path":"crates/labcolors-core/src/constraints/exact.rs","sha256":"45bd09cd8f6f74860a0a3ffeb410ee2c3cf91b4868877abbc949e724b61e1ccd"},{"kind":"rust-source","path":"crates/labcolors-core/src/constraints/mod.rs","sha256":"8b78b9a31e7ade3f7b99edf3fbcf2bc5d912509cd851e82f7380e2a19e25431e"},{"kind":"rust-source","path":"crates/labcolors-core/src/constraints/wcag22.rs","sha256":"985e21abccd1fcb4a3f9624d5947ff3d1af7a1bf26b6b170ea8bbef77d6f1798"},{"kind":"rust-source","path":"crates/labcolors-core/src/hash.rs","sha256":"f97a0fd7d6ad3162f0f1dfb326fccfb7ed40da9a8fa67a5b8a239a1ae2ae49c3"},{"kind":"rust-source","path":"crates/labcolors-core/src/lib.rs","sha256":"9e7b2c8e8a1d02d387c5c92c995de8f8148296c74efadf895e71318fbf0f4b0e"},{"kind":"rust-source","path":"crates/labcolors-core/src/numerics.rs","sha256":"e73a12136494f2ef9aca4e943ab38302c1439f054cecab36a552d35252c164f9"},{"kind":"rust-source","path":"crates/labcolors-core/src/observation.rs","sha256":"bb5fc6bf4ad79e15a31dc6ec28341994f7cdd5e28efe60f9c5d7ae8bed9a9a63"},{"kind":"rust-source","path":"crates/labcolors-core/src/point_support.rs","sha256":"b3be8242586c0d9952332e7c830551af1c033739e8148f836f873e6d9e304315"},{"kind":"rust-source","path":"crates/labcolors-core/src/session.rs","sha256":"0a1fdf10c9ecae497cfcd447a6af937f2b07d43b81b2cd28a50d998e11197fe9"},{"kind":"rust-source","path":"crates/labcolors-core/src/srgb8.rs","sha256":"6c95324eb05476f35f75375a9af0b2b4a41b8b2978c46e67d2ce1aea5adde342"},{"kind":"rust-source","path":"crates/labcolors-core/src/wcag22.rs","sha256":"7ba7864eb7e73789bad6c63c64a4dc2dcc08c2da6921375fb9564fca230c2780"},{"kind":"rust-source","path":"crates/labcolors-core/src/wcag22/kernel.rs","sha256":"c97980c1ca2c7ea9cabff9c8d2fb7282773cca180ae15948391c29c9d6196040"},{"kind":"rust-source","path":"crates/labcolors-core/src/wcag22/q55_data.rs","sha256":"af4d23d6b70c45ce6efa839e7dda4bb0a61f6aae43cb805af6fa9b29e6c3bae2"},{"kind":"rust-source","path":"crates/labcolors-core/src/wcag22_evidence.rs","sha256":"3c5a75b07254c6071a64700af208a64987d0f0ea9698eadc54a9e74585ce1f72"}],"source_negative_controls":42,"universal_algebraic_certificate":{"basis_point_scale_instantiation":10000,"domain":"integers; Q55 scale Q>0; anchor L>=D>=0; lighter monotonicity L2>=L1>D>=0; darker monotonicity L>D2>=D1>=0; current/baseline denominators b,q>0; basis-point scale B>0 instantiated as 10000; p>0; a>=0; 0<=drop_bps<=B","identities":["three explicit anchor-surplus formulas after denominator clearing","reference distance is monotone increasing in lighter L","reference distance is monotone decreasing in darker D","positive-baseline retained threshold is p*(B-drop)/(q*B)","a/b >= p*(B-drop)/(q*B) iff a*q*B >= p*(B-drop)*b"],"method":"exact-sparse-integer-polynomial-identities-plus-positive-denominator-order-lemma-v1","nonpositive_baseline_case":"max(baseline,0)=0; retained threshold is exactly zero","symbolic_mutation_controls":{"anchor_coefficients_and_denominator":6,"retained_cross_product":5},"wolfram_language_cross_check":{"query":"FullSimplify[{20 g/d - 0 == 20 g/d, 20 g/d - 2 == (20 g - 2 d)/d, 20 g/d - 7/2 == (40 g - 7 d)/(2 d), Equivalent[a/b >= p (s-x)/(q s), a q s >= p (s-x) b], Max[p/q, 0] (s-x)/s == Piecewise[{{0, p <= 0}}, p (s-x)/(q s)]}, Assumptions -> Element[{a,b,p,q,s,x,g,d}, Integers] && a >= 0 && b > 0 && q > 0 && s > 0 && 0 <= x <= s && d > 0 && g >= 0]","query_sha256":"8cdbb9964583030c8b92498961896cb2a98613f1cb31eb7c54acdf8e16beff10","result":"{True, True, True, True, True}","result_sha256":"13a8f2ee8d0fde335a638e46d7cc8a8427b9a1437c77d22cfcf925bb87fa6303"}},"verifier_sha256":"d137ef2c6c780e4d0a8ee40b5f379c139ae116c1eaf753ce6060ac4e1f60d51d"} diff --git a/crates/labcolors-core/src/appearance.rs b/crates/labcolors-core/src/appearance.rs index 32c2c0b7..f6e02fb1 100644 --- a/crates/labcolors-core/src/appearance.rs +++ b/crates/labcolors-core/src/appearance.rs @@ -13,10 +13,18 @@ //! //! Code-owned adapter представлен sealed borrowed IR и исполняется тем же //! evaluator-ом, что результат декларативной компиляции. Структурное равенство -//! статического IR результату compiler-а закреплено proof-тестом; production- -//! артефакт не содержит admission/topology compiler. +//! статического IR результату compiler-а закреплено proof-тестом. Compiler входит +//! в production Core: любой внутренний lowerer собирает тот же нейтральный +//! Paint/Surface/Occurrence DAG, не добавляя в физику словарь клиента. + +#![cfg_attr( + not(test), + expect( + dead_code, + reason = "production physical-graph compiler lands before its Core lowerer consumer" + ) +)] -#[cfg(test)] use std::collections::BTreeSet; use crate::Srgb8; @@ -133,7 +141,6 @@ impl ProgramOccurrenceBindingV1 { } /// Paint-конструкторы point-домена. Ни один вариант не знает Surface. -#[cfg(test)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum PaintSpec { /// Непрозрачный encoded-sRGB8 Paint из цветового входа. @@ -151,7 +158,6 @@ pub(crate) enum PaintSpec { }, } -#[cfg(test)] impl PaintSpec { fn id(&self) -> PaintId { match self { @@ -162,7 +168,6 @@ impl PaintSpec { /// Surface либо приходит извне как point-вход, либо является видимым /// результатом объявленного occurrence. -#[cfg(test)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum SurfaceSpec { Input { @@ -175,7 +180,6 @@ pub(crate) enum SurfaceSpec { }, } -#[cfg(test)] impl SurfaceSpec { fn id(&self) -> SurfaceId { match self { @@ -185,7 +189,6 @@ impl SurfaceSpec { } /// Единственная canonical application Paint к backdrop Surface. -#[cfg(test)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct OccurrenceSpec { pub(crate) id: OccurrenceId, @@ -194,9 +197,7 @@ pub(crate) struct OccurrenceSpec { pub(crate) profile: CompositionProfileV1, } -/// Ошибки AOT-компиляции декларации. Compiler принадлежит proof-поверхности и -/// не входит в production-артефакт. -#[cfg(test)] +/// Ошибки атомарной AOT-компиляции физической декларации. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum CompileError { DuplicateColorInput { @@ -258,7 +259,6 @@ pub(crate) enum CompileError { /// Ошибки admission runtime bindings. Исполнение начинается только после /// полной проверки, поэтому частичного результата нет. -#[cfg(test)] #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum BindingError { DuplicateColorBinding { @@ -290,8 +290,16 @@ pub(crate) enum BindingError { }, OpacityOutOfDomain { input: OpacityInputId, - message: String, + reason: crate::composition::OpacityAdmissionErrorV1, }, + /// Bindings were admitted against a different exact typed input schema. + IncompatibleAdmittedBindings, + /// Scratch belongs to a different physical graph shape. Reusing storage is + /// allowed only when every typed output domain has the same cardinality. + IncompatibleWorkspace, + /// A fallible allocation needed to prepare bindings, scratch or an owned + /// result could not be satisfied. No numeric policy limit is implied. + ResourceExhausted, } /// Единственный отказ sealed point-adapter-а: невалидная authored alpha. @@ -309,7 +317,6 @@ impl PointOpacityError { } /// Плоские декларации до атомарной компиляции. Порядок списков смысла не несёт. -#[cfg(test)] #[derive(Debug, Clone, PartialEq)] pub(crate) struct AppearanceGraphSpec { color_inputs: Vec, @@ -320,7 +327,6 @@ pub(crate) struct AppearanceGraphSpec { occurrences: Vec, } -#[cfg(test)] impl AppearanceGraphSpec { pub(crate) fn new( color_inputs: Vec, @@ -598,7 +604,6 @@ impl AppearanceGraphSpec { } } -#[cfg(test)] fn adjacent_duplicate(sorted: &[T]) -> Option { sorted .windows(2) @@ -609,7 +614,6 @@ fn adjacent_duplicate(sorted: &[T]) -> Option { /// Topo для functional dependency graph: каждый узел имеет не более одной /// зависимости. При цикле возвращает только его реальные узлы, а не весь /// заблокированный Kahn-остаток. -#[cfg(test)] fn canonical_functional_topology( keys: &[K], dependencies: &[Option], @@ -649,7 +653,6 @@ fn canonical_functional_topology( /// Итеративный functional-cycle detector: O(V), без риска переполнить стек на /// большом входе и без ложного включения деревьев, ведущих в цикл. -#[cfg(test)] fn functional_cycle_members(dependencies: &[Option]) -> Vec { const UNSEEN: u8 = 0; const ACTIVE: u8 = 1; @@ -694,7 +697,6 @@ fn functional_cycle_members(dependencies: &[Option]) -> Vec { } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -#[cfg(test)] enum RenderKey { Surface(SurfaceId), Occurrence(OccurrenceId), @@ -719,6 +721,14 @@ enum CompiledPaintSpec { }, } +impl CompiledPaintSpec { + const fn id(&self) -> PaintId { + match self { + Self::Solid { id, .. } | Self::Opacity { id, .. } => *id, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] enum CompiledSurfaceSpec { Input { @@ -731,7 +741,6 @@ enum CompiledSurfaceSpec { }, } -#[cfg(test)] impl CompiledSurfaceSpec { fn id(&self) -> SurfaceId { match self { @@ -750,9 +759,28 @@ struct CompiledOccurrenceSpec { profile: CompositionProfileV1, } +/// Cold-bound canonical Paint position for allocation-free repeated lookup. +/// +/// Both fields are private to this module: callers can obtain a slot only from +/// a compiled graph and cannot forge a raw ordinal. The retained nominal ID is +/// checked again by every evaluation view before the ordinal is dereferenced. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct CompiledPaintSlotV1 { + index: usize, + id: PaintId, +} + +/// Cold-bound canonical Occurrence position for allocation-free repeated +/// lookup. As with [`CompiledPaintSlotV1`], construction remains sealed inside +/// the compiled appearance graph and every use revalidates the exact ID. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct CompiledOccurrenceSlotV1 { + index: usize, + id: OccurrenceId, +} + /// Канонический compiled IR с индексными ссылками: после проверки bindings /// исполнение самих Paint/Surface/Occurrence узлов линейно по их числу. -#[cfg(test)] #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CompiledAppearanceGraph { color_inputs: Vec, @@ -766,8 +794,8 @@ pub(crate) struct CompiledAppearanceGraph { } /// Borrowed runtime-представление уже проверенного compiled IR. Оно отделяет -/// исполнение от compiler-а и позволяет статическим внутренним adapter-ам не -/// тащить admission/topology machinery в конечный binary. +/// исполнение от compiler-а, а статическим внутренним adapter-ам — исполнять +/// заранее доказанную топологию без повторной компиляции. #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct CompiledInputSchema<'a> { color_inputs: &'a [ColorInputId], @@ -993,7 +1021,6 @@ pub(crate) fn point_program_matches(compiled: &CompiledAppearanceGraph) -> bool } /// Runtime bindings одного атомарного evaluate. -#[cfg(test)] #[derive(Debug, Clone, PartialEq)] pub(crate) struct AppearanceBindings { colors: Vec<(ColorInputId, Srgb8)>, @@ -1001,7 +1028,6 @@ pub(crate) struct AppearanceBindings { opacities: Vec<(OpacityInputId, f64)>, } -#[cfg(test)] impl AppearanceBindings { pub(crate) fn new( mut colors: Vec<(ColorInputId, Srgb8)>, @@ -1019,6 +1045,104 @@ impl AppearanceBindings { } } +/// Один раз полностью проверенные runtime bindings в typed physical domain. +/// +/// IDs остаются рядом со значениями: это позволяет fail-closed отвергнуть +/// случайное применение bindings к другому compiled input schema. Значения +/// alpha уже представлены [`crate::composition::AdmittedOpacityV1`], поэтому +/// steady-state evaluate не повторяет numeric admission. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct AdmittedAppearanceBindings { + colors: Vec<(ColorInputId, Srgb8)>, + surfaces: Vec<(SurfaceInputPortId, Srgb8)>, + opacities: Vec<(OpacityInputId, crate::composition::AdmittedOpacityV1)>, +} + +impl AdmittedAppearanceBindings { + /// Fallibly duplicate one fully admitted value for an independent Session. + /// + /// An ordinary [`Clone`] can abort the process on allocation failure. The + /// runtime attachment boundary uses this method so resource exhaustion is + /// returned before a partially prepared Session can escape. + pub(crate) fn try_clone_v1(&self) -> Result { + fn copy_vec(source: &[T]) -> Result, BindingError> { + let mut copied = Vec::new(); + copied + .try_reserve_exact(source.len()) + .map_err(|_| BindingError::ResourceExhausted)?; + copied.extend_from_slice(source); + Ok(copied) + } + + Ok(Self { + colors: copy_vec(&self.colors)?, + surfaces: copy_vec(&self.surfaces)?, + opacities: copy_vec(&self.opacities)?, + }) + } + + /// Overwrite the complete canonical Surface-input slice from one borrowed + /// value source. + /// + /// The exact typed schema is checked in full before `value_at` can run or + /// any admitted value can change. After that O(N) preflight, `value_at` is + /// called exactly once per canonical input and every destination value is + /// overwritten exactly once. Neither pass needs lookup or allocation. + pub(crate) fn overwrite_surface_inputs_canonical( + &mut self, + expected_inputs: impl IntoIterator, + mut value_at: impl FnMut(usize) -> Srgb8, + ) -> Result<(), BindingError> { + if !expected_inputs + .into_iter() + .eq(self.surfaces.iter().map(|(input, _)| *input)) + { + return Err(BindingError::IncompatibleAdmittedBindings); + } + + for (index, (_, value)) in self.surfaces.iter_mut().enumerate() { + *value = value_at(index); + } + Ok(()) + } + + /// Borrow the admitted Surface-input slice in its exact canonical order. + pub(crate) fn surface_inputs_canonical( + &self, + ) -> impl ExactSizeIterator + '_ { + self.surfaces.iter().copied() + } + + #[cfg(test)] + pub(crate) fn opacity_bits(&self, input: OpacityInputId) -> Option { + self.opacities + .binary_search_by_key(&input, |(bound, _)| *bound) + .ok() + .map(|index| self.opacities[index].1.bits()) + } + + fn matches_schema(&self, schema: CompiledInputSchema<'_>) -> bool { + schema.color_inputs.len() == self.colors.len() + && schema.surface_input_ports.len() == self.surfaces.len() + && schema.opacity_inputs.len() == self.opacities.len() + && schema + .color_inputs + .iter() + .zip(&self.colors) + .all(|(declared, (bound, _))| declared == bound) + && schema + .surface_input_ports + .iter() + .zip(&self.surfaces) + .all(|(declared, (bound, _))| declared == bound) + && schema + .opacity_inputs + .iter() + .zip(&self.opacities) + .all(|(declared, (bound, _))| declared == bound) + } +} + /// Материализованный encoded point Paint вне зависимости от стадии владения. /// /// Graph materialization и downstream recheck разделяют это одно физическое @@ -1210,7 +1334,6 @@ impl VisiblePointBindingV1 { } /// Полный атомарный результат evaluate в каноническом typed-ID порядке. -#[cfg(test)] #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct AppearanceEvaluation { paints: Vec, @@ -1218,7 +1341,6 @@ pub(crate) struct AppearanceEvaluation { occurrences: Vec, } -#[cfg(test)] impl AppearanceEvaluation { pub(crate) fn paint(&self, id: PaintId) -> Option<&EncodedPointPaintV1> { self.paints @@ -1242,7 +1364,195 @@ impl AppearanceEvaluation { } } -#[cfg(test)] +/// Reusable scratch для одного compiled physical graph. +/// +/// После первого fallible sizing повторные evaluate того же shape только +/// очищают slots; capacity и backing allocations остаются неизменными. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct AppearanceWorkspaceShape { + paints: usize, + surfaces: usize, + occurrences: usize, +} + +impl AppearanceWorkspaceShape { + const fn of(program: CompiledAppearanceProgram<'_>) -> Self { + Self { + paints: program.paints.len(), + surfaces: program.surfaces.len(), + occurrences: program.occurrences.len(), + } + } +} + +#[derive(Debug)] +pub(crate) struct AppearanceWorkspace { + shape: AppearanceWorkspaceShape, + paints: Vec>, + surfaces: Vec>, + occurrences: Vec>, +} + +impl AppearanceWorkspace { + fn for_program(program: CompiledAppearanceProgram<'_>) -> Result { + let shape = AppearanceWorkspaceShape::of(program); + let mut workspace = Self { + shape, + paints: Vec::new(), + surfaces: Vec::new(), + occurrences: Vec::new(), + }; + initialise_workspace_slots(&mut workspace.paints, shape.paints)?; + initialise_workspace_slots(&mut workspace.surfaces, shape.surfaces)?; + initialise_workspace_slots(&mut workspace.occurrences, shape.occurrences)?; + Ok(workspace) + } + + fn prepare(&mut self, program: CompiledAppearanceProgram<'_>) -> Result<(), BindingError> { + if self.shape != AppearanceWorkspaceShape::of(program) { + return Err(BindingError::IncompatibleWorkspace); + } + self.paints.fill(None); + self.surfaces.fill(None); + self.occurrences.fill(None); + Ok(()) + } + + #[cfg(test)] + pub(crate) fn storage_signature(&self) -> [(usize, usize); 3] { + [ + (self.paints.as_ptr() as usize, self.paints.capacity()), + (self.surfaces.as_ptr() as usize, self.surfaces.capacity()), + ( + self.occurrences.as_ptr() as usize, + self.occurrences.capacity(), + ), + ] + } +} + +fn initialise_workspace_slots( + slots: &mut Vec>, + required_len: usize, +) -> Result<(), BindingError> { + slots + .try_reserve_exact(required_len) + .map_err(|_| BindingError::ResourceExhausted)?; + slots.resize(required_len, None); + Ok(()) +} + +/// Borrowed allocation-free result of one workspace evaluation. +/// +/// The mutable workspace borrow prevents another evaluate from invalidating +/// these values while a consumer is still reading them. +#[derive(Debug)] +pub(crate) struct AppearanceEvaluationView<'program, 'workspace> { + program: CompiledAppearanceProgram<'program>, + workspace: &'workspace AppearanceWorkspace, +} + +impl AppearanceEvaluationView<'_, '_> { + pub(crate) fn paint(&self, id: PaintId) -> Option<&EncodedPointPaintV1> { + let index = self + .program + .paints + .binary_search_by_key(&id, CompiledPaintSpec::id) + .ok()?; + self.workspace.paints[index].as_ref() + } + + /// Resolve a compiler-minted Paint slot in constant time. + /// + /// A slot from a graph whose canonical ordinal names another Paint is + /// rejected before returning workspace data. No fallback ID lookup occurs. + pub(crate) fn paint_at(&self, slot: CompiledPaintSlotV1) -> Option<&EncodedPointPaintV1> { + let spec = self.program.paints.get(slot.index)?; + if spec.id() != slot.id { + return None; + } + let paint = self.workspace.paints.get(slot.index)?.as_ref()?; + (paint.id == slot.id).then_some(paint) + } + + pub(crate) fn surface_rgb(&self, id: SurfaceId) -> Option<[u8; 3]> { + let index = self + .program + .surfaces + .binary_search_by_key(&id, CompiledSurfaceSpec::id) + .ok()?; + self.workspace.surfaces[index].map(Srgb8::bytes) + } + + pub(crate) fn occurrence(&self, id: OccurrenceId) -> Option<&ResolvedOccurrence> { + let index = self + .program + .occurrences + .binary_search_by_key(&id, |occurrence| occurrence.id) + .ok()?; + self.workspace.occurrences[index].as_ref() + } + + /// Resolve a compiler-minted Occurrence slot in constant time, retaining + /// exact nominal identity as the fail-closed cross-graph check. + pub(crate) fn occurrence_at( + &self, + slot: CompiledOccurrenceSlotV1, + ) -> Option<&ResolvedOccurrence> { + let spec = self.program.occurrences.get(slot.index)?; + if spec.id != slot.id { + return None; + } + let occurrence = self.workspace.occurrences.get(slot.index)?.as_ref()?; + (occurrence.id == slot.id).then_some(occurrence) + } + + pub(crate) fn occurrences(&self) -> impl ExactSizeIterator + '_ { + self.workspace.occurrences.iter().map(|occurrence| { + occurrence + .as_ref() + .unwrap_or_else(|| unreachable!("render topo covers every Occurrence")) + }) + } + + fn try_to_owned(&self) -> Result { + let mut paints = Vec::new(); + paints + .try_reserve_exact(self.workspace.paints.len()) + .map_err(|_| BindingError::ResourceExhausted)?; + for paint in &self.workspace.paints { + paints.push(paint.unwrap_or_else(|| unreachable!("Paint topo covers every node"))); + } + + let mut surfaces = Vec::new(); + surfaces + .try_reserve_exact(self.workspace.surfaces.len()) + .map_err(|_| BindingError::ResourceExhausted)?; + for (spec, value) in self.program.surfaces.iter().zip(&self.workspace.surfaces) { + surfaces.push(( + spec.id(), + value.unwrap_or_else(|| unreachable!("render topo covers every Surface")), + )); + } + + let mut occurrences = Vec::new(); + occurrences + .try_reserve_exact(self.workspace.occurrences.len()) + .map_err(|_| BindingError::ResourceExhausted)?; + for occurrence in &self.workspace.occurrences { + occurrences.push( + occurrence.unwrap_or_else(|| unreachable!("render topo covers every Occurrence")), + ); + } + + Ok(AppearanceEvaluation { + paints, + surfaces, + occurrences, + }) + } +} + impl CompiledAppearanceGraph { fn program(&self) -> CompiledAppearanceProgram<'_> { CompiledAppearanceProgram::from_validated_parts( @@ -1259,26 +1569,94 @@ impl CompiledAppearanceGraph { ) } + #[cfg(test)] fn matches_program(&self, program: CompiledAppearanceProgram<'_>) -> bool { self.program() == program } + /// Bind one Paint identity to its canonical compiled ordinal. + /// + /// This is the only cold lookup. Repeated evaluations consume the sealed + /// slot through [`AppearanceEvaluationView::paint_at`] without searching. + pub(crate) fn bind_paint(&self, id: PaintId) -> Option { + let index = self + .paints + .binary_search_by_key(&id, CompiledPaintSpec::id) + .ok()?; + Some(CompiledPaintSlotV1 { index, id }) + } + + /// Bind one Occurrence identity to its canonical compiled ordinal. + pub(crate) fn bind_occurrence(&self, id: OccurrenceId) -> Option { + let index = self + .occurrences + .binary_search_by_key(&id, |occurrence| occurrence.id) + .ok()?; + Some(CompiledOccurrenceSlotV1 { index, id }) + } + + /// Canonical client-owned occurrence identities emitted by this program. + pub(crate) fn occurrence_ids(&self) -> impl ExactSizeIterator + '_ { + self.occurrences.iter().map(|occurrence| occurrence.id) + } + + /// Canonical physical Surface-input schema accepted by this program. + pub(crate) fn surface_input_ports( + &self, + ) -> impl ExactSizeIterator + '_ { + self.surface_input_ports.iter().copied() + } + + /// Проверить полный typed schema и один раз понизить authored alpha в + /// admitted physical values. Результат можно клонировать для независимых + /// runtime callers без повторной numeric admission. + pub(crate) fn admit_bindings( + &self, + bindings: &AppearanceBindings, + ) -> Result { + self.program().admit_bindings(bindings) + } + + /// Fallible one-time allocation of scratch for this exact physical shape. + pub(crate) fn new_workspace(&self) -> Result { + AppearanceWorkspace::for_program(self.program()) + } + + /// Allocation-free steady-state execution over already admitted bindings. + pub(crate) fn evaluate_admitted_into<'workspace>( + &self, + bindings: &AdmittedAppearanceBindings, + workspace: &'workspace mut AppearanceWorkspace, + ) -> Result, BindingError> { + self.program().evaluate_admitted_into(bindings, workspace) + } + + /// Cold convenience внутри Core для callers, которым нужен owned result. + /// Hot Session обязан хранить admitted bindings и workspace между вызовами. pub(crate) fn evaluate( &self, bindings: &AppearanceBindings, ) -> Result { - self.program().evaluate(bindings) + let admitted = self.admit_bindings(bindings)?; + let mut workspace = self.new_workspace()?; + self.evaluate_admitted_into(&admitted, &mut workspace)? + .try_to_owned() } } -impl CompiledAppearanceProgram<'_> { - /// Проверить bindings, материализовать Paint DAG один раз и исполнить - /// Surface/Occurrence DAG один раз. Частичный результат не возвращается. - #[cfg(test)] - pub(crate) fn evaluate( +impl<'program> CompiledAppearanceProgram<'program> { + const fn input_schema(self) -> CompiledInputSchema<'program> { + CompiledInputSchema::new( + self.color_inputs, + self.surface_input_ports, + self.opacity_inputs, + ) + } + + fn admit_bindings( &self, bindings: &AppearanceBindings, - ) -> Result { + ) -> Result { let colors = &bindings.colors; if let Some(window) = colors.windows(2).find(|window| window[0].0 == window[1].0) { return Err(BindingError::DuplicateColorBinding { input: window[0].0 }); @@ -1337,15 +1715,54 @@ impl CompiledAppearanceProgram<'_> { return Err(BindingError::UnexpectedSurfaceInputBinding { input: *bound }); } } + + let mut admitted_colors = Vec::new(); + admitted_colors + .try_reserve_exact(colors.len()) + .map_err(|_| BindingError::ResourceExhausted)?; + admitted_colors.extend(colors.iter().copied()); + + let mut admitted_surfaces = Vec::new(); + admitted_surfaces + .try_reserve_exact(surfaces.len()) + .map_err(|_| BindingError::ResourceExhausted)?; + admitted_surfaces.extend(surfaces.iter().copied()); + + let mut admitted_opacities = Vec::new(); + admitted_opacities + .try_reserve_exact(opacities.len()) + .map_err(|_| BindingError::ResourceExhausted)?; for (input, alpha) in opacities { - if let Err(message) = crate::composition::validate_alpha(*alpha) { - return Err(BindingError::OpacityOutOfDomain { + let value = crate::composition::AdmittedOpacityV1::new(*alpha).map_err(|reason| { + BindingError::OpacityOutOfDomain { input: *input, - message, - }); - } + reason, + } + })?; + admitted_opacities.push((*input, value)); } + Ok(AdmittedAppearanceBindings { + colors: admitted_colors, + surfaces: admitted_surfaces, + opacities: admitted_opacities, + }) + } + + fn evaluate_admitted_into<'workspace>( + self, + bindings: &AdmittedAppearanceBindings, + workspace: &'workspace mut AppearanceWorkspace, + ) -> Result, BindingError> { + if !bindings.matches_schema(self.input_schema()) { + return Err(BindingError::IncompatibleAdmittedBindings); + } + workspace.prepare(self)?; + + let colors = &bindings.colors; + let surfaces = &bindings.surfaces; + let opacities = &bindings.opacities; + let color_value = |id: ColorInputId| -> Srgb8 { let index = colors .binary_search_by_key(&id, |(bound, _)| *bound) @@ -1362,55 +1779,27 @@ impl CompiledAppearanceProgram<'_> { let index = opacities .binary_search_by_key(&id, |(bound, _)| *bound) .unwrap_or_else(|_| unreachable!("bindings were matched before evaluation")); - crate::composition::AdmittedOpacityV1::new(opacities[index].1) - .unwrap_or_else(|_| unreachable!("opacity bindings were admitted before execution")) + opacities[index].1 }; - let mut resolved_paints: Vec> = vec![None; self.paints.len()]; - let mut resolved_surfaces: Vec> = vec![None; self.surfaces.len()]; - let mut resolved_occurrences: Vec> = - vec![None; self.occurrences.len()]; self.execute_into( color_value, surface_value, opacity_value, - &mut resolved_paints, - &mut resolved_surfaces, - &mut resolved_occurrences, + &mut workspace.paints, + &mut workspace.surfaces, + &mut workspace.occurrences, ); - let paints = resolved_paints - .into_iter() - .map(|paint| paint.unwrap_or_else(|| unreachable!("Paint topo covers every node"))) - .collect(); - let surfaces = self - .surfaces - .iter() - .zip(resolved_surfaces) - .map(|(surface, value)| { - ( - surface.id(), - value.unwrap_or_else(|| unreachable!("render topo covers every Surface")), - ) - }) - .collect(); - let occurrences = resolved_occurrences - .into_iter() - .map(|occurrence| { - occurrence.unwrap_or_else(|| unreachable!("render topo covers every Occurrence")) - }) - .collect(); - - Ok(AppearanceEvaluation { - paints, - surfaces, - occurrences, + Ok(AppearanceEvaluationView { + program: self, + workspace, }) } /// Единственное исполнение compiled IR. Scratch принадлежит caller-у: - /// static adapter использует stack arrays, test-only generic admission — - /// динамические buffers. Алгоритм и сертификат при этом общие. + /// static adapter использует stack arrays, generic admission — + /// владеющие buffers. Алгоритм и сертификат при этом общие. fn execute_into( &self, color_value: C, diff --git a/crates/labcolors-core/src/appearance_graph_tests.rs b/crates/labcolors-core/src/appearance_graph_tests.rs index 9e25144f..14a047af 100644 --- a/crates/labcolors-core/src/appearance_graph_tests.rs +++ b/crates/labcolors-core/src/appearance_graph_tests.rs @@ -3,15 +3,17 @@ //! Граф владеет только физической топологией: Paint материализуется независимо //! от подложки, Occurrence является его единственным применением к Surface, а //! `surfaceFrom` лишь даёт видимому результату повторно используемую identity. -//! Словарь Pair/role и perception-утверждения сюда не входят. +//! Клиентский словарь и perception-утверждения сюда не входят. + +use std::cell::Cell; use proptest::prelude::*; use crate::Srgb8; use crate::appearance::{ - AppearanceBindings, AppearanceGraphSpec, BindingError, ColorInputId, CompileError, - CompositionProfileV1, EncodedPointPaintV1, OccurrenceId, OccurrenceSpec, OpacityInputId, - PaintId, PaintSpec, SurfaceId, SurfaceInputPortId, SurfaceSpec, + AdmittedAppearanceBindings, AppearanceBindings, AppearanceGraphSpec, BindingError, + ColorInputId, CompileError, CompositionProfileV1, EncodedPointPaintV1, OccurrenceId, + OccurrenceSpec, OpacityInputId, PaintId, PaintSpec, SurfaceId, SurfaceInputPortId, SurfaceSpec, }; use crate::constraints::Evaluator; @@ -27,6 +29,7 @@ const OTHER_PAINT: PaintId = PaintId::new(41); const CONTEXT_SURFACE: SurfaceId = SurfaceId::new(90); const DERIVED_SURFACE: SurfaceId = SurfaceId::new(2); const FILL_OCCURRENCE: OccurrenceId = OccurrenceId::new(800); +const OTHER_OCCURRENCE: OccurrenceId = OccurrenceId::new(400); fn point_component(reverse_paints: bool, reverse_surfaces: bool) -> AppearanceGraphSpec { let mut paints = vec![ @@ -80,6 +83,71 @@ fn bindings(source: [u8; 3], opacity: f64, context: [u8; 3]) -> AppearanceBindin ) } +fn slot_component(reverse_declarations: bool) -> AppearanceGraphSpec { + let mut paints = vec![ + PaintSpec::Solid { + id: SOLID_PAINT, + color: SOURCE, + }, + PaintSpec::Solid { + id: FILL_PAINT, + color: OTHER_SOURCE, + }, + ]; + let mut occurrences = vec![ + OccurrenceSpec { + id: FILL_OCCURRENCE, + subject: FILL_PAINT, + against: CONTEXT_SURFACE, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }, + OccurrenceSpec { + id: OTHER_OCCURRENCE, + subject: SOLID_PAINT, + against: CONTEXT_SURFACE, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }, + ]; + if reverse_declarations { + paints.reverse(); + occurrences.reverse(); + } + + AppearanceGraphSpec::new( + vec![SOURCE, OTHER_SOURCE], + vec![CONTEXT], + vec![], + paints, + vec![SurfaceSpec::Input { + id: CONTEXT_SURFACE, + port: CONTEXT, + }], + occurrences, + ) +} + +fn admitted_surface_triplet() -> AdmittedAppearanceBindings { + let inputs = [ + SurfaceInputPortId::new(30), + SurfaceInputPortId::new(10), + SurfaceInputPortId::new(20), + ]; + let graph = AppearanceGraphSpec::new(vec![], inputs.to_vec(), vec![], vec![], vec![], vec![]) + .compile() + .unwrap(); + graph + .admit_bindings(&AppearanceBindings::new( + vec![], + vec![ + (inputs[0], Srgb8::new([30; 3])), + (inputs[1], Srgb8::new([10; 3])), + (inputs[2], Srgb8::new([20; 3])), + ], + vec![], + )) + .unwrap() +} + #[test] fn static_exact_program_is_declarative_topology_plus_typed_constraint() { let compiled = crate::appearance::point_opacity_over_surface_declarative_spec() @@ -148,6 +216,127 @@ fn compile_and_evaluate_ignore_declaration_order() { assert_eq!(expected, both_reversed.evaluate(&values).unwrap()); } +#[test] +fn compiled_slots_bind_found_ids_and_reject_missing_ids() { + let graph = point_component(false, false).compile().unwrap(); + + assert!(graph.bind_paint(SOLID_PAINT).is_some()); + assert!(graph.bind_paint(FILL_PAINT).is_some()); + assert!(graph.bind_paint(PaintId::new(999)).is_none()); + assert!(graph.bind_occurrence(FILL_OCCURRENCE).is_some()); + assert!(graph.bind_occurrence(OccurrenceId::new(999)).is_none()); +} + +#[test] +fn compiled_slots_have_canonical_ordinals_across_declaration_permutations() { + let canonical = slot_component(false).compile().unwrap(); + let reversed = slot_component(true).compile().unwrap(); + + for paint in [FILL_PAINT, SOLID_PAINT] { + assert_eq!(canonical.bind_paint(paint), reversed.bind_paint(paint)); + } + for occurrence in [OTHER_OCCURRENCE, FILL_OCCURRENCE] { + assert_eq!( + canonical.bind_occurrence(occurrence), + reversed.bind_occurrence(occurrence) + ); + } +} + +#[test] +fn evaluation_view_rejects_same_ordinal_slots_with_different_nominal_ids() { + let compile_single = |paint, occurrence| { + AppearanceGraphSpec::new( + vec![SOURCE], + vec![CONTEXT], + vec![], + vec![PaintSpec::Solid { + id: paint, + color: SOURCE, + }], + vec![SurfaceSpec::Input { + id: CONTEXT_SURFACE, + port: CONTEXT, + }], + vec![OccurrenceSpec { + id: occurrence, + subject: paint, + against: CONTEXT_SURFACE, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }], + ) + .compile() + .unwrap() + }; + let graph = compile_single(FILL_PAINT, FILL_OCCURRENCE); + let incompatible = compile_single(OTHER_PAINT, OTHER_OCCURRENCE); + let incompatible_paint = incompatible.bind_paint(OTHER_PAINT).unwrap(); + let incompatible_occurrence = incompatible.bind_occurrence(OTHER_OCCURRENCE).unwrap(); + let admitted = graph + .admit_bindings(&AppearanceBindings::new( + vec![(SOURCE, Srgb8::new([10, 20, 30]))], + vec![(CONTEXT, Srgb8::new([40, 50, 60]))], + vec![], + )) + .unwrap(); + let mut workspace = graph.new_workspace().unwrap(); + let evaluated = graph + .evaluate_admitted_into(&admitted, &mut workspace) + .unwrap(); + + assert!(evaluated.paint_at(incompatible_paint).is_none()); + assert!(evaluated.occurrence_at(incompatible_occurrence).is_none()); +} + +#[test] +fn prebound_view_lookup_returns_exact_values_and_allocates_nothing() { + let graph = point_component(false, false).compile().unwrap(); + let admitted = graph + .admit_bindings(&bindings([0xFF, 0xA1, 0x00], 0.122, [0xFF; 3])) + .unwrap(); + let mut workspace = graph.new_workspace().unwrap(); + let evaluated = graph + .evaluate_admitted_into(&admitted, &mut workspace) + .unwrap(); + let paint_slot = graph.bind_paint(FILL_PAINT).unwrap(); + let occurrence_slot = graph.bind_occurrence(FILL_OCCURRENCE).unwrap(); + + let (resolved, allocations) = crate::test_support::measured_allocations(|| { + let paint = evaluated.paint_at(paint_slot); + let occurrence = evaluated.occurrence_at(occurrence_slot); + ( + paint.map(|paint| (paint.id(), paint.source(), paint.opacity_bits())), + occurrence.map(|occurrence| { + ( + occurrence.id(), + occurrence.subject(), + occurrence.against(), + occurrence.backdrop(), + occurrence.visible(), + *occurrence.certificate(), + ) + }), + ) + }); + + assert_eq!(allocations, 0); + assert_eq!( + resolved.0, + Some(( + FILL_PAINT, + Srgb8::new([0xFF, 0xA1, 0x00]), + 0.122f64.to_bits(), + )) + ); + let occurrence = resolved.1.unwrap(); + assert_eq!(occurrence.0, FILL_OCCURRENCE); + assert_eq!(occurrence.1, FILL_PAINT); + assert_eq!(occurrence.2, CONTEXT_SURFACE); + assert_eq!(occurrence.3, [0xFF; 3]); + assert_eq!(occurrence.4, [0xFF, 0xF4, 0xE0]); + assert_eq!(occurrence.5.replay(), [0xFF, 0xF4, 0xE0]); +} + #[test] fn complete_typed_id_renaming_does_not_change_physics() { let source = ColorInputId::new(700); @@ -239,6 +428,96 @@ fn complete_typed_id_renaming_does_not_change_physics() { ); } +#[test] +fn equal_transport_numbers_remain_opaque_in_a_nested_occurrence_graph() { + let first_color = ColorInputId::new(41); + let second_color = ColorInputId::new(7); + let surface_port = SurfaceInputPortId::new(41); + let first_opacity = OpacityInputId::new(41); + let second_opacity = OpacityInputId::new(7); + let first_solid = PaintId::new(41); + let first_modulated = PaintId::new(42); + let second_solid = PaintId::new(7); + let second_modulated = PaintId::new(8); + let backdrop = SurfaceId::new(41); + let derived = SurfaceId::new(7); + let first_occurrence = OccurrenceId::new(41); + let second_occurrence = OccurrenceId::new(7); + + let graph = AppearanceGraphSpec::new( + vec![first_color, second_color], + vec![surface_port], + vec![first_opacity, second_opacity], + vec![ + PaintSpec::Opacity { + id: first_modulated, + source: first_solid, + opacity: first_opacity, + }, + PaintSpec::Solid { + id: second_solid, + color: second_color, + }, + PaintSpec::Opacity { + id: second_modulated, + source: second_solid, + opacity: second_opacity, + }, + PaintSpec::Solid { + id: first_solid, + color: first_color, + }, + ], + vec![ + SurfaceSpec::FromOccurrence { + id: derived, + occurrence: first_occurrence, + }, + SurfaceSpec::Input { + id: backdrop, + port: surface_port, + }, + ], + vec![ + OccurrenceSpec { + id: second_occurrence, + subject: second_modulated, + against: derived, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }, + OccurrenceSpec { + id: first_occurrence, + subject: first_modulated, + against: backdrop, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }, + ], + ) + .compile() + .unwrap(); + let evaluated = graph + .evaluate(&AppearanceBindings::new( + vec![ + (first_color, Srgb8::new([200, 80, 40])), + (second_color, Srgb8::new([30, 160, 230])), + ], + vec![(surface_port, Srgb8::new([20, 40, 60]))], + vec![(first_opacity, 0.5), (second_opacity, 0.25)], + )) + .unwrap(); + + let first = evaluated.occurrence(first_occurrence).unwrap(); + let second = evaluated.occurrence(second_occurrence).unwrap(); + assert_eq!(first.subject(), first_modulated); + assert_eq!(first.against(), backdrop); + assert_eq!(first.visible(), [110, 60, 50]); + assert_eq!(evaluated.surface_rgb(derived), Some([110, 60, 50])); + assert_eq!(second.subject(), second_modulated); + assert_eq!(second.against(), derived); + assert_eq!(second.backdrop(), first.visible()); + assert_eq!(second.visible(), [90, 85, 95]); +} + #[test] fn occurrence_uses_the_declared_paint_not_an_unrelated_color_input() { let graph = AppearanceGraphSpec::new( @@ -852,7 +1131,8 @@ proptest! { } else { (invalid, 0.5, OPACITY) }; - let expected_message = crate::composition::validate_alpha(invalid).unwrap_err(); + let expected_reason = + crate::composition::AdmittedOpacityV1::new(invalid).unwrap_err(); prop_assert_eq!( graph.evaluate(&AppearanceBindings::new( vec![(SOURCE, Srgb8::new([1, 2, 3]))], @@ -861,7 +1141,7 @@ proptest! { )), Err(BindingError::OpacityOutOfDomain { input: expected_input, - message: expected_message, + reason: expected_reason, }) ); } @@ -1333,16 +1613,53 @@ fn evaluate_rejects_duplicate_missing_and_unexpected_bindings() { } #[test] -fn evaluate_rejects_invalid_alpha_with_the_ssot_domain_text() { +fn binding_admission_is_atomic_before_any_occurrence_is_evaluated() { + let graph = point_component(false, false).compile().unwrap(); + + crate::composition::reset_source_over_evaluation_count(); + let duplicate_surface = graph.evaluate(&AppearanceBindings::new( + vec![(SOURCE, Srgb8::new([1, 2, 3]))], + vec![ + (CONTEXT, Srgb8::new([4, 5, 6])), + (CONTEXT, Srgb8::new([7, 8, 9])), + ], + vec![(OPACITY, 0.5)], + )); + assert_eq!( + duplicate_surface, + Err(BindingError::DuplicateSurfaceInputBinding { input: CONTEXT }) + ); + assert_eq!(crate::composition::source_over_evaluation_count(), 0); + + crate::composition::reset_source_over_evaluation_count(); + let invalid_opacity = graph.evaluate(&bindings([1, 2, 3], f64::NAN, [4, 5, 6])); + assert_eq!( + invalid_opacity, + Err(BindingError::OpacityOutOfDomain { + input: OPACITY, + reason: crate::composition::OpacityAdmissionErrorV1::NonFinite, + }) + ); + assert_eq!(crate::composition::source_over_evaluation_count(), 0); + + let evaluated = graph + .evaluate(&bindings([1, 2, 3], 0.5, [4, 5, 6])) + .unwrap(); + assert_eq!(crate::composition::source_over_evaluation_count(), 1); + assert!(evaluated.occurrence(FILL_OCCURRENCE).is_some()); +} + +#[test] +fn evaluate_rejects_invalid_alpha_with_the_typed_admission_reason() { let graph = point_component(false, false).compile().unwrap(); for bad_alpha in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -0.1, 1.5] { - let expected = crate::alpha::composite_over_srgb8([1, 2, 3], bad_alpha, [4, 5, 6]) - .expect_err("композитор обязан отвергать тот же домен alpha"); + let expected = crate::composition::AdmittedOpacityV1::new(bad_alpha) + .expect_err("общий admission обязан отвергать alpha"); assert_eq!( graph.evaluate(&bindings([1, 2, 3], bad_alpha, [4, 5, 6])), Err(BindingError::OpacityOutOfDomain { input: OPACITY, - message: expected, + reason: expected, }), "alpha={bad_alpha}" ); @@ -1364,3 +1681,251 @@ fn signed_zero_opacity_has_one_canonical_state() { 0.0f64.to_bits() ); } + +#[test] +fn canonical_surface_overwrite_rejects_every_schema_drift_before_read_or_mutation() { + let mut admitted = admitted_surface_triplet(); + let first = SurfaceInputPortId::new(10); + let second = SurfaceInputPortId::new(20); + let third = SurfaceInputPortId::new(30); + let original = [ + (first, Srgb8::new([10; 3])), + (second, Srgb8::new([20; 3])), + (third, Srgb8::new([30; 3])), + ]; + let reordered = [second, first, third]; + let relabelled = [first, SurfaceInputPortId::new(21), third]; + let truncated = [first, second]; + let extended = [first, second, third, SurfaceInputPortId::new(40)]; + + for expected in [ + reordered.as_slice(), + relabelled.as_slice(), + truncated.as_slice(), + extended.as_slice(), + ] { + let reads = Cell::new(0); + let (result, allocations) = crate::test_support::measured_allocations(|| { + admitted.overwrite_surface_inputs_canonical(expected.iter().copied(), |_| { + reads.set(reads.get() + 1); + Srgb8::new([0; 3]) + }) + }); + + assert_eq!(result, Err(BindingError::IncompatibleAdmittedBindings)); + assert_eq!(reads.get(), 0); + assert_eq!(allocations, 0); + assert!(admitted.surface_inputs_canonical().eq(original)); + } +} + +#[test] +fn canonical_surface_overwrite_reads_once_and_exposes_all_values_without_allocation() { + let mut admitted = admitted_surface_triplet(); + let expected_inputs = [ + SurfaceInputPortId::new(10), + SurfaceInputPortId::new(20), + SurfaceInputPortId::new(30), + ]; + + admitted + .overwrite_surface_inputs_canonical(expected_inputs, |index| Srgb8::new([index as u8; 3])) + .unwrap(); + + let values = [ + Srgb8::new([101, 102, 103]), + Srgb8::new([111, 112, 113]), + Srgb8::new([121, 122, 123]), + ]; + let reads = Cell::new([0_usize; 3]); + let (result, allocations) = crate::test_support::measured_allocations(|| { + admitted.overwrite_surface_inputs_canonical(expected_inputs, |index| { + let mut counts = reads.get(); + counts[index] += 1; + reads.set(counts); + values[index] + })?; + Ok::<_, BindingError>( + admitted + .surface_inputs_canonical() + .eq(expected_inputs.into_iter().zip(values)), + ) + }); + + assert_eq!(result, Ok(true)); + assert_eq!(reads.get(), [1, 1, 1]); + assert_eq!(allocations, 0); +} + +#[test] +fn admitted_surface_runtime_exposes_only_canonical_bulk_seams() { + let source = include_str!("appearance.rs"); + let forbidden = concat!("set_surface_", "input"); + assert!( + !source.contains(forbidden), + "per-port Surface mutation must not return after the canonical bulk cut" + ); + for required in [ + "overwrite_surface_inputs_canonical", + "surface_inputs_canonical", + ] { + assert!( + source.contains(required), + "canonical runtime seam `{required}` must remain" + ); + } +} + +#[test] +fn admitted_bindings_and_workspace_are_reused_without_storage_churn() { + let graph = point_component(false, false).compile().unwrap(); + let mut admitted = graph + .admit_bindings(&bindings([200, 80, 40], 0.5, [20, 40, 60])) + .unwrap(); + let mut independent = admitted.try_clone_v1().unwrap(); + assert_eq!(independent, admitted); + independent + .overwrite_surface_inputs_canonical([CONTEXT], |_| Srgb8::new([1, 2, 3])) + .unwrap(); + assert_ne!(independent, admitted); + assert_eq!(admitted.opacity_bits(OPACITY), Some(0.5f64.to_bits())); + assert_eq!( + graph.occurrence_ids().collect::>(), + vec![FILL_OCCURRENCE] + ); + assert_eq!( + graph.surface_input_ports().collect::>(), + vec![CONTEXT] + ); + + let mut workspace = graph.new_workspace().unwrap(); + let storage = workspace.storage_signature(); + for (backdrop, expected) in [ + ([20, 40, 60], [110, 60, 50]), + ([100, 100, 100], [150, 90, 70]), + ] { + admitted + .overwrite_surface_inputs_canonical([CONTEXT], |_| Srgb8::new(backdrop)) + .unwrap(); + { + let evaluated = graph + .evaluate_admitted_into(&admitted, &mut workspace) + .unwrap(); + assert_eq!( + evaluated.paint(FILL_PAINT).unwrap().opacity_bits(), + 0.5f64.to_bits() + ); + assert_eq!(evaluated.surface_rgb(CONTEXT_SURFACE), Some(backdrop)); + assert_eq!( + evaluated.occurrence(FILL_OCCURRENCE).unwrap().visible(), + expected + ); + assert_eq!( + evaluated + .occurrences() + .map(|occurrence| occurrence.id()) + .collect::>(), + vec![FILL_OCCURRENCE] + ); + } + assert_eq!(workspace.storage_signature(), storage); + } +} + +#[test] +fn admitted_schema_and_workspace_shape_mismatches_fail_before_composition() { + let graph = point_component(false, false).compile().unwrap(); + let admitted = graph + .admit_bindings(&bindings([1, 2, 3], 0.5, [4, 5, 6])) + .unwrap(); + let empty = AppearanceGraphSpec::new(vec![], vec![], vec![], vec![], vec![], vec![]) + .compile() + .unwrap(); + let mut wrong_workspace = empty.new_workspace().unwrap(); + let wrong_storage = wrong_workspace.storage_signature(); + + crate::composition::reset_source_over_evaluation_count(); + assert_eq!( + graph + .evaluate_admitted_into(&admitted, &mut wrong_workspace) + .unwrap_err(), + BindingError::IncompatibleWorkspace + ); + assert_eq!(crate::composition::source_over_evaluation_count(), 0); + assert_eq!(wrong_workspace.storage_signature(), wrong_storage); + + let other_source = ColorInputId::new(100); + let other_context = SurfaceInputPortId::new(101); + let other_opacity = OpacityInputId::new(102); + let other_solid = PaintId::new(103); + let other_paint = PaintId::new(104); + let other_surface = SurfaceId::new(105); + let other_derived = SurfaceId::new(106); + let other_occurrence = OccurrenceId::new(107); + let other = AppearanceGraphSpec::new( + vec![other_source], + vec![other_context], + vec![other_opacity], + vec![ + PaintSpec::Solid { + id: other_solid, + color: other_source, + }, + PaintSpec::Opacity { + id: other_paint, + source: other_solid, + opacity: other_opacity, + }, + ], + vec![ + SurfaceSpec::Input { + id: other_surface, + port: other_context, + }, + SurfaceSpec::FromOccurrence { + id: other_derived, + occurrence: other_occurrence, + }, + ], + vec![OccurrenceSpec { + id: other_occurrence, + subject: other_paint, + against: other_surface, + profile: CompositionProfileV1::EncodedSrgb8SourceOverV1, + }], + ) + .compile() + .unwrap(); + let other_admitted = other + .admit_bindings(&AppearanceBindings::new( + vec![(other_source, Srgb8::new([1, 2, 3]))], + vec![(other_context, Srgb8::new([4, 5, 6]))], + vec![(other_opacity, 0.5)], + )) + .unwrap(); + let mut workspace = graph.new_workspace().unwrap(); + let storage = workspace.storage_signature(); + + assert_eq!( + graph + .evaluate_admitted_into(&other_admitted, &mut workspace) + .unwrap_err(), + BindingError::IncompatibleAdmittedBindings + ); + assert_eq!(crate::composition::source_over_evaluation_count(), 0); + assert_eq!(workspace.storage_signature(), storage); +} + +#[test] +fn invalid_opacity_is_rejected_during_admission_before_any_composition() { + let graph = point_component(false, false).compile().unwrap(); + crate::composition::reset_source_over_evaluation_count(); + assert_eq!( + graph.admit_bindings(&bindings([1, 2, 3], f64::NAN, [4, 5, 6])), + Err(BindingError::OpacityOutOfDomain { + input: OPACITY, + reason: crate::composition::OpacityAdmissionErrorV1::NonFinite, + }) + ); + assert_eq!(crate::composition::source_over_evaluation_count(), 0); +} diff --git a/crates/labcolors-core/src/composition.rs b/crates/labcolors-core/src/composition.rs index ffeb7dd0..3f5469dc 100644 --- a/crates/labcolors-core/src/composition.rs +++ b/crates/labcolors-core/src/composition.rs @@ -103,13 +103,6 @@ pub(crate) fn source_over_channel_srgb8(tint: u8, alpha: f64, backdrop: u8) -> u source_over_channel_value(tint, alpha, backdrop).round() as u8 } -#[cfg(test)] -pub(crate) fn validate_alpha(alpha: f64) -> Result<(), String> { - AdmittedOpacityV1::new(alpha) - .map(|_| ()) - .map_err(|_| format!("alpha вне конечного [0,1]: {alpha}")) -} - pub(crate) fn source_over_srgb8( tint: [u8; 3], alpha: f64, diff --git a/crates/labcolors-core/src/config/tests.rs b/crates/labcolors-core/src/config/tests.rs index 3270bcca..ad1397a9 100644 --- a/crates/labcolors-core/src/config/tests.rs +++ b/crates/labcolors-core/src/config/tests.rs @@ -785,8 +785,6 @@ fn consumed_roles_diff_is_empty_against_labui_contract() { }; assert!(hits("label-on-accent") && hits("bg-material-thick") && hits("tint-static-dark-4")); assert!(!hits("fx-glow-inverted") && !hits("label-danger-primary")); - // COLLAPSED_ROLES не пуст — декларация причин присутствует. - assert!(!COLLAPSED_ROLES.is_empty()); } /// Glob-сопоставление паттернов [`COLLAPSED_ROLES`] (`*` — любая подстрока): diff --git a/crates/labcolors-core/src/constraints/mod.rs b/crates/labcolors-core/src/constraints/mod.rs index fdcaf610..c5618c13 100644 --- a/crates/labcolors-core/src/constraints/mod.rs +++ b/crates/labcolors-core/src/constraints/mod.rs @@ -120,8 +120,7 @@ impl ClassifiedMeasurement { Pass(Pass), @@ -154,6 +153,26 @@ pub(crate) type PointViolation = , >>::Violation; +/// One statically dispatched point evaluator/classifier family. +/// +/// The first executable Program slice is deliberately homogeneous: every +/// compiled invocation has this evaluator's one typed invocation and result +/// family. The trait remains sealed through both parent protocols; this slice +/// contains no dynamic registry or open payload enum. +pub(crate) trait PointEvaluatorV1: + Sized + + Evaluator + + HardClassifier, PointMeasurement> +{ +} + +impl PointEvaluatorV1 for Evaluation where + Evaluation: Sized + + Evaluator + + HardClassifier, PointMeasurement> +{ +} + type BoundVisiblePointMeasurement = BoundEvidence< VisiblePointBindingV1, >::Identity, diff --git a/crates/labcolors-core/src/generic_boundary_tests.rs b/crates/labcolors-core/src/generic_boundary_tests.rs new file mode 100644 index 00000000..13952cfd --- /dev/null +++ b/crates/labcolors-core/src/generic_boundary_tests.rs @@ -0,0 +1,537 @@ +const APPEARANCE_SOURCE: &str = include_str!("appearance.rs"); +const LCS_OCCURRENCE_SOURCE: &str = include_str!("lcs_occurrence.rs"); +const OBSERVATION_SOURCE: &str = include_str!("observation.rs"); +const POINT_SUPPORT_SOURCE: &str = include_str!("point_support.rs"); +const PROGRAM_SESSION_SOURCE: &str = include_str!("program_session.rs"); +const SESSION_SOURCE: &str = include_str!("session.rs"); + +const GENERIC_SOURCES: [(&str, &str); 3] = [ + ("appearance.rs", APPEARANCE_SOURCE), + ("lcs_occurrence.rs", LCS_OCCURRENCE_SOURCE), + ("program_session.rs", PROGRAM_SESSION_SOURCE), +]; + +const CLIENT_OR_LEGACY_VOCABULARY: [&str; 15] = [ + "Lab UI", + "ThemeConfig", + "RoleRecipe", + "RoleSpec", + "NamedRoleTable", + "PairFill", + "PairLabel", + "Glow", + "Material", + "Ladder", + "AlphaAnalog", + "themeHandle", + "resolveTheme", + "Primary", + "Danger", +]; + +fn source_scope<'a>(source: &'a str, start: &str, end: &str) -> &'a str { + let start = source + .find(start) + .unwrap_or_else(|| panic!("missing source boundary start `{start}`")); + let tail = &source[start..]; + let end = tail + .find(end) + .unwrap_or_else(|| panic!("missing source boundary end `{end}` after `{start}`")); + &tail[..end] +} + +fn normalized_source_scope(source: &str, start: &str, end: &str) -> String { + source_scope(source, start, end) + .split_whitespace() + .collect::>() + .join(" ") +} + +#[test] +fn generic_physical_and_transport_modules_contain_no_client_or_legacy_vocabulary() { + for (path, source) in GENERIC_SOURCES { + for forbidden in CLIENT_OR_LEGACY_VOCABULARY { + assert!( + !source.contains(forbidden), + "{path} must remain client-semantic agnostic; found `{forbidden}`" + ); + } + } +} + +#[test] +fn shared_observation_ssot_has_one_backing_without_lifecycle_or_adapter_facades() { + assert_eq!( + normalized_source_scope( + OBSERVATION_SOURCE, + "struct ObservedScenarioSet {", + "impl ObservedScenarioSet", + ), + concat!( + "struct ObservedScenarioSet { ", + "cases: Box<[PhysicalScenario]>, ", + "values: Box<[Srgb8]>, ", + "provenance: Box<[ScenarioId]>, ", + "}", + ), + "the canonical scenario set must keep one flat physical backing", + ); + assert_eq!( + normalized_source_scope( + OBSERVATION_SOURCE, + "struct ObservationBackingV1 {", + "/// Sealed observation admitted", + ), + concat!( + "struct ObservationBackingV1 { ", + "schema: CanonicalObservationSchemaV1, ", + "set: ObservedScenarioSet, ", + "}", + ), + "the shared backing must own only canonical schema and scenario data", + ); + assert_eq!( + normalized_source_scope( + OBSERVATION_SOURCE, + "pub(crate) struct RevisionBoundObservationV1 {", + "impl RevisionBoundObservationV1", + ), + concat!( + "pub(crate) struct RevisionBoundObservationV1 { ", + "stream: ObservationStreamId, ", + "revision: Revision, ", + "backing: Rc, ", + "}", + ), + "revision identity must wrap exactly one shared immutable backing", + ); + assert_eq!( + OBSERVATION_SOURCE + .matches("pub(crate) struct RevisionBoundObservationV1") + .count(), + 1, + "production must define exactly one revision-bound observation value", + ); + assert!( + OBSERVATION_SOURCE.contains("use std::rc::Rc;"), + "single-threaded Core observations must use Rc", + ); + assert!( + OBSERVATION_SOURCE + .contains("pub(crate) struct CanonicalObservationSchemaV1(Rc<[SurfaceInputPortId]>);"), + "compiled schema and observations must share the same Rc-backed schema", + ); + for forbidden in ["std::sync::Arc", "Arc<", "RefCell<", "Mutex<", "RwLock<"] { + assert!( + !OBSERVATION_SOURCE.contains(forbidden), + "immutable single-threaded observation backing must not acquire `{forbidden}`", + ); + } + + assert_eq!( + normalized_source_scope( + SESSION_SOURCE, + "pub(crate) enum SessionState {", + "impl SessionState", + ), + concat!( + "pub(crate) enum SessionState { ", + "Waiting, ", + "Ready { current: Verified, }, ", + "Stale { previous: Verified, }, ", + "Failed { cause: Violation, previous: Option, }, ", + "}", + ), + "lifecycle state must not duplicate the current raw observation", + ); + assert_eq!( + normalized_source_scope( + SESSION_SOURCE, + "enum SessionObservationHeadV1 {", + "impl ObservationOwnerV1 for SessionObservationHeadV1", + ), + concat!( + "enum SessionObservationHeadV1 { ", + "Empty, ", + "Unknown(RevisionBoundUnknownV1), ", + "Observed(RevisionBoundObservationV1), ", + "}", + ), + "raw Empty/Unknown/Observed must remain separate from lifecycle state", + ); + let session_owner = source_scope( + SESSION_SOURCE, + "pub(crate) struct Session {", + "impl Session", + ); + for required in [ + "schema: CanonicalObservationSchemaV1,", + "raw_head: SessionObservationHeadV1,", + "state: SessionState,", + ] { + assert_eq!( + session_owner.matches(required).count(), + 1, + "Session must own exactly one `{required}` field", + ); + } + for forbidden in [ + "current_unknown", + "observation: RevisionBoundObservationV1", + "unknown: RevisionBoundUnknownV1", + ] { + assert!( + !session_owner.contains(forbidden), + "Session owner must not duplicate raw storage through `{forbidden}`", + ); + } + assert_eq!( + SESSION_SOURCE.matches("pub(crate) struct Session<").count(), + 1, + "production must have exactly one generic revision-bound Session owner", + ); + for required in [ + "type Verified: SessionEvidenceV1;", + "type Violation: SessionEvidenceV1;", + ".is_same_binding_as(expected_observation)", + "SessionUpdateError::EvidenceBindingInvariant", + ] { + assert!( + SESSION_SOURCE.contains(required), + "Session must reject detached evaluator evidence; missing `{required}`", + ); + } + assert_eq!( + POINT_SUPPORT_SOURCE + .matches("impl SessionPlanV1 for CompiledPointSupportRecheckV1") + .count() + + PROGRAM_SESSION_SOURCE + .matches("SessionPlanV1 for ProgramSessionPlan") + .count(), + 2, + "only the point-support and Program compiled plans may inhabit Session", + ); + for (path, source) in [ + ("session.rs", SESSION_SOURCE), + ("point_support.rs", POINT_SUPPORT_SOURCE), + ("program_session.rs", PROGRAM_SESSION_SOURCE), + ] { + for forbidden in [ + "PointSupportSessionV1", + "PointSupportSessionStateV1", + "BoundPointSupportRecheckV1", + "into_session_recheck", + "ObservationStreamBinding", + "ProgramExpired", + "Weak<", + ] { + assert!( + !source.contains(forbidden), + "{path} must not restore a second owner or adapter `{forbidden}`", + ); + } + } + + let consuming_entry = source_scope( + POINT_SUPPORT_SOURCE, + "impl SessionPlanV1 for CompiledPointSupportRecheckV1 {", + "pub(crate) enum PointSupportEvaluationErrorV1", + ); + for required in [ + "observation: RevisionBoundObservationV1,", + "evaluate_bound_point_support(self, &observation)?", + "assessment.bind(observation)", + ] { + assert!( + consuming_entry.contains(required), + "point support must consume the shared observation directly; missing `{required}`", + ); + } + for forbidden in [ + ".clone()", + ".to_vec()", + "ObservationAdapter", + "adapt_observation", + ] { + assert!( + !consuming_entry.contains(forbidden), + "point-support entry must not introduce observation façade `{forbidden}`", + ); + } + + let evaluator = source_scope( + POINT_SUPPORT_SOURCE, + "fn evaluate_bound_point_support(", + "fn reference_distance(", + ); + assert!( + evaluator.contains("observation: &RevisionBoundObservationV1"), + "the evaluator must borrow the shared revision-bound observation", + ); + assert_eq!( + evaluator.matches(".physical_values(case_index)").count(), + 1, + "point support must read each canonical physical case through the observation API", + ); + let physical_values = evaluator + .find(".physical_values(case_index)") + .expect("physical-values route must exist"); + let indexed_surface = evaluator[physical_values..] + .find("values.get(*surface_index)") + .expect("the prebound surface index must read from physical values"); + assert!( + indexed_surface > 0, + "surface lookup must follow the canonical physical-values projection", + ); + for forbidden in [ + "physical_bindings", + "SurfaceInputBinding", + ".clone()", + ".to_vec()", + "ObservationAdapter", + "adapt_observation", + ] { + assert!( + !evaluator.contains(forbidden), + "point evaluator must not reconstruct or adapt observations through `{forbidden}`", + ); + } + + let report = source_scope( + POINT_SUPPORT_SOURCE, + "pub(crate) struct RevisionBoundPointSupportReportV1 {", + "impl RevisionBoundPointSupportReportV1", + ); + assert_eq!( + report + .matches("observation: RevisionBoundObservationV1,") + .count(), + 1, + "a report must own the same revision-bound observation without a parallel snapshot", + ); + + for (path, source) in [ + ("observation.rs", OBSERVATION_SOURCE), + ("session.rs", SESSION_SOURCE), + ("point_support.rs", POINT_SUPPORT_SOURCE), + ] { + for forbidden in [ + "FrozenObservationV1", + "PriorObservation", + "Availability", + "ObservationSnapshot", + "WaitingRecheckV1", + "StaleRecheckV1", + "PresentationHoldV1", + "HoldErrorV1", + "reuse_for", + "ReuseErrorV1", + "FinalRecheckOutcomeV1", + "ObservationAdapter", + "adapt_observation", + ] { + assert!( + !source.contains(forbidden), + "{path} must not restore compatibility or adapter API `{forbidden}`", + ); + } + } +} + +#[test] +fn encoded_point_transport_does_not_claim_lcs_observation_types() { + for forbidden in ["TristimulusSample", "LcsOccurrence", "AppearanceState"] { + assert!( + !PROGRAM_SESSION_SOURCE.contains(forbidden), + "program_session.rs is encoded point transport, not `{forbidden}` evidence" + ); + } +} + +#[test] +fn program_session_module_docs_disclaim_transport_only_scope() { + let module_docs = PROGRAM_SESSION_SOURCE + .lines() + .take_while(|line| line.starts_with("//!")) + .collect::>() + .join("\n") + .to_ascii_lowercase(); + + for required in ["transport-only", "encoded", "not", "lcs", "evidence"] { + assert!( + module_docs.contains(required), + "program_session.rs module docs must explicitly disclaim transport-only scope; missing `{required}`" + ); + } +} + +#[test] +fn program_compiler_cannot_regrow_the_superseded_runtime_facade() { + for forbidden in [ + "PointRenderOwner", + "PointRenderAttachError", + "ObservationStreamBinding", + "SurfaceUpdate", + "OutputValueV1", + "ExecutionFrame", + "SessionState", + ] { + assert!( + !PROGRAM_SESSION_SOURCE.contains(forbidden), + "program_session.rs must remain compiler/lowering-only; found superseded `{forbidden}`" + ); + } +} + +#[test] +fn current_og0_program_epoch_has_one_explicit_group_without_scenario_scope_creep() { + fn declaration<'a>(source: &'a str, start: &str, end: &str) -> &'a str { + let start = source + .find(start) + .unwrap_or_else(|| panic!("missing OG0 declaration start `{start}`")); + let end = source[start..] + .find(end) + .map(|offset| start + offset) + .unwrap_or_else(|| panic!("missing OG0 declaration end `{end}`")); + &source[start..end] + } + + // This pins only the current private OG0 Program/epoch shape. It does not + // prescribe how a future explicit join or independent component API works. + let program = declaration( + PROGRAM_SESSION_SOURCE, + "pub struct Program<", + "impl Program", + ); + let epoch = declaration( + PROGRAM_SESSION_SOURCE, + "struct ProgramEpochV1<", + "/// Fully validated immutable Program", + ); + assert_eq!( + program + .matches("observation_group: ObservationGroup,") + .count(), + 1, + "the current OG0 authored Program owns one explicit atomic group" + ); + assert_eq!( + epoch + .matches("observation_group: CompiledObservationGroupV1,") + .count(), + 1, + "the current OG0 epoch must retain that one compiled group" + ); + for (name, scope) in [("Program", program), ("ProgramEpochV1", epoch)] { + for forbidden in ["Vec", "Scenario"] { + assert!( + !scope.contains(forbidden), + "current OG0 {name} declaration must not grow `{forbidden}` scope" + ); + } + } + + for forbidden in [ + "Vec", + "ScenarioId", + "ScenarioSet", + "ObservedScenarioSet", + "GraphTemplate", + "Cartesian", + "wasm_bindgen", + "serde", + "Dto", + "DTO", + ] { + assert!( + !PROGRAM_SESSION_SOURCE.contains(forbidden), + "current OG0 transport module must not acquire `{forbidden}` scope" + ); + } +} + +#[test] +fn f0_signal_transform_has_no_renderer_alias_or_raw_xyz_production_constructor() { + for forbidden in [ + "RenderProfileId", + "AppearanceContextReleaseId", + "enum ObserveError", + "fn observe(", + "pub(crate) fn new(\n xyz", + ] { + assert!( + !LCS_OCCURRENCE_SOURCE.contains(forbidden), + "lcs_occurrence.rs must keep the F0 identity boundary sealed; found `{forbidden}`", + ); + } + + for required in [ + "ColorimetricTransformReleaseId", + "AppearanceContextSchemaReleaseId", + "fn admitted_binding(", + "match output_profile", + "fn derive_sample_with_binding(", + "binding.transform_release()", + "xyz_d65_from_srgb8_v1", + "ModeledTristimulusProvenanceV1", + "ModeledTristimulusDerivationV1", + ] { + assert!( + LCS_OCCURRENCE_SOURCE.contains(required), + "lcs_occurrence.rs must retain the sealed F0 route; missing `{required}`", + ); + } + + let raw_xyz_declaration = LCS_OCCURRENCE_SOURCE + .lines() + .find(|line| line.contains("fn try_from_registered_xyz(")) + .expect("registered raw-XYZ admission must remain visible to this source gate"); + assert_eq!( + raw_xyz_declaration.trim(), + "fn try_from_registered_xyz(", + "registered raw-XYZ admission must remain module-private", + ); + + let raw_frame_declaration = LCS_OCCURRENCE_SOURCE + .lines() + .find(|line| line.contains("const fn registered(")) + .expect("registered frame constructor must remain visible to this source gate"); + assert_eq!( + raw_frame_declaration.trim(), + "const fn registered(", + "registered frame construction must remain module-private", + ); + + let dispatch_start = LCS_OCCURRENCE_SOURCE + .find("fn admitted_binding(") + .expect("binding dispatch must remain present"); + let dispatch_end = LCS_OCCURRENCE_SOURCE[dispatch_start..] + .find("/// Derive one modeled tristimulus") + .map(|offset| dispatch_start + offset) + .expect("modeled derivation docs must delimit the dispatch source gate"); + let dispatch_source = &LCS_OCCURRENCE_SOURCE[dispatch_start..dispatch_end]; + assert!( + !dispatch_source.contains("_ =>"), + "closed F0 profile/transform dispatch must not silently fall back", + ); +} + +#[test] +fn f0_modeled_derivation_mints_no_observation_or_bounded_evidence() { + for forbidden in [ + "BoundEvidence", + "NumericalDecisionEvidenceV1", + "CanonicalFiniteBoundedEvidenceV1", + "SourceOverCertificateV1", + "GlowCompositeCertificateV1", + "RendererCapability", + "RenderObservation", + "crate::constraints", + "crate::wcag22_evidence", + ] { + assert!( + !LCS_OCCURRENCE_SOURCE.contains(forbidden), + "deterministic signal lowering cannot mint `{forbidden}`", + ); + } +} diff --git a/crates/labcolors-core/src/joint.rs b/crates/labcolors-core/src/joint.rs index 9cbde260..7c25657d 100644 --- a/crates/labcolors-core/src/joint.rs +++ b/crates/labcolors-core/src/joint.rs @@ -25,7 +25,10 @@ use crate::session::SessionObservationBindingPermitV1; /// каждого target occurrence. Конкретные Exact/WCAG/readability payload-и /// остаются в evaluator-модулях и не образуют центральный enum. pub(crate) trait JointPointEvaluatorV1: Clone + Debug + PartialEq { - type Invocation: Clone + Debug + PartialEq; + /// Joint execution repeats one invocation across the complete physical + /// matrix. Requiring a value type here keeps that repetition allocation-free + /// instead of hiding an arbitrary `Clone` behind the engine's preflight. + type Invocation: Copy + Debug + PartialEq; type PassEvidence: Clone + Debug + PartialEq; type ViolationEvidence: Clone + Debug + PartialEq; type Error: Clone + Debug + PartialEq; @@ -44,7 +47,7 @@ where + PartialEq + Evaluator + HardClassifier, PointMeasurement>, - PointInvocation: Clone + Debug + PartialEq, + PointInvocation: Copy + Debug + PartialEq, VisiblePointPassEvidence: Clone + Debug + PartialEq, VisiblePointViolationEvidence: Clone + Debug + PartialEq, >::Error: Clone + Debug + PartialEq, @@ -99,7 +102,7 @@ impl JointCandidateTupleV1 { /// Order-free candidate domain. Policy не участвует в его construction. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct JointCandidateSetV1 { - candidates: Box<[JointCandidateTupleV1]>, + candidates: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -125,20 +128,26 @@ impl JointCandidateSetV1 { return Err(CandidateSetErrorV1::DuplicateOrdinal(pair[0].ordinal)); } } - for (index, first) in candidates.iter().enumerate() { - if let Some(second) = candidates[index + 1..] - .iter() - .find(|second| first.lower == second.lower && first.upper == second.upper) - { - return Err(CandidateSetErrorV1::DuplicatePhysicalTuple { - first: first.ordinal, - second: second.ordinal, - }); - } + // Group equal physical tuples without auxiliary storage. The explicit + // ordinal tie-break makes every group canonical even though the sort is + // unstable. We inspect every duplicate group and retain the same error + // precedence as the former ordinal-order scan: the smallest first + // ordinal, followed by the smallest matching second ordinal. + candidates.sort_unstable_by(|left, right| { + candidate_physical_key(left) + .cmp(&candidate_physical_key(right)) + .then_with(|| left.ordinal.cmp(&right.ordinal)) + }); + let duplicate = candidates + .windows(2) + .filter(|pair| pair[0].lower == pair[1].lower && pair[0].upper == pair[1].upper) + .map(|pair| (pair[0].ordinal, pair[1].ordinal)) + .min(); + if let Some((first, second)) = duplicate { + return Err(CandidateSetErrorV1::DuplicatePhysicalTuple { first, second }); } - Ok(Self { - candidates: candidates.into_boxed_slice(), - }) + candidates.sort_unstable_by_key(|candidate| candidate.ordinal); + Ok(Self { candidates }) } pub(crate) fn candidates(&self) -> &[JointCandidateTupleV1] { @@ -146,6 +155,19 @@ impl JointCandidateSetV1 { } } +fn candidate_physical_key( + candidate: &JointCandidateTupleV1, +) -> (PaintId, Srgb8, u64, PaintId, Srgb8, u64) { + ( + candidate.lower.id(), + candidate.lower.source(), + candidate.lower.opacity().bits(), + candidate.upper.id(), + candidate.upper.source(), + candidate.upper.opacity().bits(), + ) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub(crate) struct JointConstraintIdV1(u32); @@ -191,8 +213,6 @@ where } } -pub(crate) type JointHardConstraintV1 = PointwiseJointHardConstraintV1; - impl PointwiseJointHardConstraintV1 { pub(crate) fn exact( id: JointConstraintIdV1, @@ -218,7 +238,8 @@ mod observation_seal { pub(crate) trait JointObservationV1: observation_seal::Sealed + Debug + PartialEq { fn case_count(&self) -> usize; - fn surface_at(&self, case_index: usize, surface: SurfaceInputPortId) -> Option; + fn bind_surface(&self, surface: SurfaceInputPortId) -> Option; + fn value_at_bound(&self, case_index: usize, bound_index: usize) -> Option; fn provenance(&self, case_index: usize) -> Option<&[ScenarioId]>; } @@ -226,15 +247,15 @@ impl observation_seal::Sealed for RevisionBoundObservationV1 {} impl JointObservationV1 for RevisionBoundObservationV1 { fn case_count(&self) -> usize { - self.set().cases().len() + self.physical_case_count() + } + + fn bind_surface(&self, surface: SurfaceInputPortId) -> Option { + self.schema().binary_search(&surface).ok() } - fn surface_at(&self, case_index: usize, surface: SurfaceInputPortId) -> Option { - let bindings = self.physical_bindings(case_index)?; - let index = bindings - .binary_search_by_key(&surface, |binding| binding.port()) - .ok()?; - Some(bindings[index].value()) + fn value_at_bound(&self, case_index: usize, bound_index: usize) -> Option { + self.physical_values(case_index)?.get(bound_index).copied() } fn provenance(&self, case_index: usize) -> Option<&[ScenarioId]> { @@ -264,8 +285,12 @@ impl JointObservationV1 for StaticJointObservationV1 { 1 } - fn surface_at(&self, case_index: usize, surface: SurfaceInputPortId) -> Option { - (case_index == 0 && surface == self.root_surface).then_some(self.root) + fn bind_surface(&self, surface: SurfaceInputPortId) -> Option { + (surface == self.root_surface).then_some(0) + } + + fn value_at_bound(&self, case_index: usize, bound_index: usize) -> Option { + (case_index == 0 && bound_index == 0).then_some(self.root) } fn provenance(&self, _case_index: usize) -> Option<&[ScenarioId]> { @@ -283,11 +308,9 @@ where root_surface: SurfaceInputPortId, lower_paint: PaintId, upper_paint: PaintId, - constraints: Box<[PointwiseJointHardConstraintV1]>, + constraints: Vec>, } -pub(crate) type JointPointProgramV1 = PointwiseJointPointProgramV1; - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum JointProgramErrorV1 { SamePaintIdentity(PaintId), @@ -299,7 +322,7 @@ impl PointwiseJointPointProgramV1 { root_surface: SurfaceInputPortId, lower_paint: PaintId, upper_paint: PaintId, - constraints: Vec, + constraints: Vec>, ) -> Result { Self::with_evaluator( ExactSrgb8IdentityV1, @@ -336,7 +359,7 @@ where root_surface, lower_paint, upper_paint, - constraints: constraints.into_boxed_slice(), + constraints, }) } @@ -345,7 +368,7 @@ where } pub(crate) fn evaluate_static( - &self, + self, candidates: JointCandidateSetV1, observation: StaticJointObservationV1, ) -> Result< @@ -356,7 +379,7 @@ where } pub(crate) fn evaluate_revision_bound( - &self, + self, candidates: JointCandidateSetV1, observation: RevisionBoundObservationV1, _permit: SessionObservationBindingPermitV1, @@ -368,7 +391,7 @@ where } fn evaluate_owned( - &self, + self, candidates: JointCandidateSetV1, observation: Observation, ) -> Result< @@ -379,46 +402,64 @@ where Observation: JointObservationV1, { self.validate_candidates(&candidates)?; - self.validate_observation(&observation)?; + let root_binding = self.bind_observation_surface(&observation)?; let (execution_count, cell_count) = checked_joint_cardinality_raw( candidates.candidates.len(), observation.case_count(), self.constraints.len(), ) .map_err(|_| PointwiseJointReportErrorV1::ResourceExhausted)?; + let mut feasible_ordinals = Vec::new(); + feasible_ordinals + .try_reserve_exact(candidates.candidates.len()) + .map_err(|_| PointwiseJointReportErrorV1::ResourceExhausted)?; + feasible_ordinals.extend( + candidates + .candidates + .iter() + .map(|candidate| candidate.ordinal), + ); let matrices = self.execute( candidates.candidates(), &observation, + root_binding, execution_count, cell_count, )?; + retain_feasible_ordinals(&mut feasible_ordinals, &matrices.cells); Ok(PointwiseFullHardReportV1 { program_identity: self.identity(), - program: self.clone(), + program: self, candidates, observation, executions: matrices.executions, cells: matrices.cells, + feasible_ordinals, }) } - fn validate_observation( + fn bind_observation_surface( &self, observation: &Observation, - ) -> Result<(), PointwiseJointReportErrorV1> + ) -> Result> where Observation: JointObservationV1, { + let Some(root_binding) = observation.bind_surface(self.root_surface) else { + return Err(PointwiseJointReportErrorV1::MissingRootSurface( + self.root_surface, + )); + }; if (0..observation.case_count()).any(|case_index| { observation - .surface_at(case_index, self.root_surface) + .value_at_bound(case_index, root_binding) .is_none() }) { return Err(PointwiseJointReportErrorV1::MissingRootSurface( self.root_surface, )); } - Ok(()) + Ok(root_binding) } fn validate_candidates( @@ -450,6 +491,7 @@ where &self, candidates: &[JointCandidateTupleV1], observation: &Observation, + root_binding: usize, execution_count: usize, cell_count: usize, ) -> Result< @@ -470,9 +512,9 @@ where for candidate in candidates { for case_index in 0..observation.case_count() { - let root = observation - .surface_at(case_index, self.root_surface) - .unwrap_or_else(|| unreachable!("joint observation passed keyed preflight")); + let root = observation.value_at_bound(case_index, root_binding).ok_or( + PointwiseJointReportErrorV1::MissingRootSurface(self.root_surface), + )?; let lower = PointOpacityOverSurfaceV1::evaluate_admitted( candidate.lower.source().bytes(), candidate.lower.opacity(), @@ -494,7 +536,7 @@ where upper, }); - for constraint in self.constraints.iter().cloned() { + for constraint in &self.constraints { let occurrence = match constraint.target { JointVisibleTargetV1::Lower => &lower, JointVisibleTargetV1::Upper => &upper, @@ -503,7 +545,7 @@ where // поэтому частичная матрица не называется FullHardReport. let decision = match self .evaluator - .assess(occurrence, constraint.invocation.clone()) + .assess(occurrence, constraint.invocation) .map_err(PointwiseJointReportErrorV1::Evaluator)? { HardDecision::Pass(evidence) => { @@ -526,10 +568,7 @@ where debug_assert_eq!(executions.len(), execution_count); debug_assert_eq!(cells.len(), cell_count); - Ok(PointwiseJointEvaluationMatricesV1 { - executions: executions.into_boxed_slice(), - cells: cells.into_boxed_slice(), - }) + Ok(PointwiseJointEvaluationMatricesV1 { executions, cells }) } } @@ -549,8 +588,6 @@ where ResourceExhausted, } -pub(crate) type JointReportErrorV1 = PointwiseJointReportErrorV1; - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum JointCapacityErrorV1 { ResourceExhausted, @@ -574,18 +611,41 @@ pub(crate) fn checked_joint_cardinality( candidates: usize, cases: usize, constraints: usize, -) -> Result<(usize, usize), JointReportErrorV1> { +) -> Result<(usize, usize), PointwiseJointReportErrorV1> { checked_joint_cardinality_raw(candidates, cases, constraints) - .map_err(|_| JointReportErrorV1::ResourceExhausted) + .map_err(|_| PointwiseJointReportErrorV1::ResourceExhausted) } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, PartialEq)] struct PointwiseJointEvaluationMatricesV1 where Evaluation: JointPointEvaluatorV1, { - executions: Box<[JointExecutionRecordV1]>, - cells: Box<[PointwiseJointConstraintCellV1]>, + executions: Vec, + cells: Vec>, +} + +fn retain_feasible_ordinals( + feasible: &mut Vec, + cells: &[PointwiseJointConstraintCellV1], +) where + Evaluation: JointPointEvaluatorV1, +{ + // Both vectors are candidate-major and ordinal-canonical. One cursor keeps + // classification O(candidates + cells), including the empty-constraint case. + let mut cell_index = 0; + feasible.retain(|ordinal| { + let mut passes = true; + while let Some(cell) = cells + .get(cell_index) + .filter(|cell| cell.ordinal == *ordinal) + { + passes &= cell.decision.is_pass(); + cell_index += 1; + } + passes + }); + debug_assert_eq!(cell_index, cells.len()); } /// Один execution record существует независимо от наличия constraint на lower. @@ -647,9 +707,6 @@ where Violation(Evaluation::ViolationEvidence), } -pub(crate) type JointConstraintDecisionV1 = - PointwiseJointConstraintDecisionV1; - impl PointwiseJointConstraintDecisionV1 where Evaluation: JointPointEvaluatorV1, @@ -714,7 +771,7 @@ where /// Полная матрица candidate x constraint x unique physical case плюс отдельная /// joint execution matrix candidate x case. Report не знает selection policy. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, PartialEq)] pub(crate) struct PointwiseFullHardReportV1 where Evaluation: JointPointEvaluatorV1, @@ -724,8 +781,9 @@ where program: PointwiseJointPointProgramV1, candidates: JointCandidateSetV1, observation: Observation, - executions: Box<[JointExecutionRecordV1]>, - cells: Box<[PointwiseJointConstraintCellV1]>, + executions: Vec, + cells: Vec>, + feasible_ordinals: Vec, } impl PointwiseFullHardReportV1 @@ -758,29 +816,17 @@ where } pub(crate) fn classify(self) -> PointwiseHardFeasibilityV1 { - let mut feasible = Vec::new(); - for candidate in self.candidates.candidates() { - if self - .cells - .iter() - .filter(|cell| cell.ordinal == candidate.ordinal) - .all(|cell| cell.decision.is_pass()) - { - feasible.push(candidate.ordinal); - } - } - if feasible.is_empty() { + if self.feasible_ordinals.is_empty() { PointwiseHardFeasibilityV1::Infeasible(self) } else { PointwiseHardFeasibilityV1::NonEmpty(PointwiseNonEmptyFeasibleJointTuplesV1 { report: self, - feasible: feasible.into_boxed_slice(), }) } } } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, PartialEq)] pub(crate) enum PointwiseHardFeasibilityV1 where Evaluation: JointPointEvaluatorV1, @@ -790,17 +836,13 @@ where NonEmpty(PointwiseNonEmptyFeasibleJointTuplesV1), } -pub(crate) type HardFeasibilityV1 = - PointwiseHardFeasibilityV1; - -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, PartialEq)] pub(crate) struct PointwiseNonEmptyFeasibleJointTuplesV1 where Evaluation: JointPointEvaluatorV1, Observation: JointObservationV1, { report: PointwiseFullHardReportV1, - feasible: Box<[CandidateOrdinalV1]>, } impl PointwiseNonEmptyFeasibleJointTuplesV1 @@ -809,42 +851,129 @@ where Observation: JointObservationV1, { pub(crate) fn feasible(&self) -> &[CandidateOrdinalV1] { - &self.feasible + &self.report.feasible_ordinals } pub(crate) fn candidate_set(&self) -> &JointCandidateSetV1 { self.report.candidate_set() } + #[expect( + clippy::result_large_err, + reason = "ownership-preserving rejection keeps the expensive report retryable without heap allocation or recomputation" + )] pub(crate) fn select( self, policy: DeclaredTotalOrderV1, - ) -> PointwiseSelectedJointTupleV1 { - let ordinal = policy - .order - .iter() - .copied() - .find(|ordinal| self.feasible.binary_search(ordinal).is_ok()) - .unwrap_or_else(|| unreachable!("validated total order covers nonempty feasible set")); - let candidate = *self + ) -> Result< + PointwiseSelectedJointTupleV1, + PointwiseSelectionFailureV1, + > { + if !policy.is_bound_to(self.report.candidate_set()) { + return Err(PointwiseSelectionFailureV1 { + feasible: self, + policy, + reason: SelectionPolicyErrorV1::CandidateDomainMismatch, + }); + } + // Canonical policy entries and feasible ordinals are both sorted by + // ordinal. One merge scan finds the feasible entry with minimum + // client-declared rank without C×log(F) lookup or auxiliary storage. + let mut feasible_index = 0; + let mut selected: Option<(usize, usize)> = None; + for (candidate_index, entry) in policy.domain.iter().enumerate() { + if self.report.feasible_ordinals.get(feasible_index) == Some(&entry.ordinal) { + if selected.is_none_or(|(rank, _)| entry.rank < rank) { + selected = Some((entry.rank, candidate_index)); + } + feasible_index += 1; + } + } + let Some((_, candidate_index)) = + selected.filter(|_| feasible_index == self.report.feasible_ordinals.len()) + else { + return Err(PointwiseSelectionFailureV1 { + feasible: self, + policy, + reason: SelectionPolicyErrorV1::InternalInvariant, + }); + }; + let Some(candidate) = self .report .candidates .candidates() - .iter() - .find(|candidate| candidate.ordinal == ordinal) - .unwrap_or_else(|| unreachable!("validated ordinal belongs to candidate set")); - PointwiseSelectedJointTupleV1 { + .get(candidate_index) + .copied() + else { + return Err(PointwiseSelectionFailureV1 { + feasible: self, + policy, + reason: SelectionPolicyErrorV1::InternalInvariant, + }); + }; + Ok(PointwiseSelectedJointTupleV1 { report: self.report, policy, candidate, - } + }) + } +} + +/// Recoverable selection rejection. A foreign/malformed policy cannot destroy +/// the expensive full report: the caller can replace only the policy and retry +/// without recomposition or evaluator execution. +#[derive(Debug, PartialEq)] +pub(crate) struct PointwiseSelectionFailureV1 +where + Evaluation: JointPointEvaluatorV1, + Observation: JointObservationV1, +{ + feasible: PointwiseNonEmptyFeasibleJointTuplesV1, + policy: DeclaredTotalOrderV1, + reason: SelectionPolicyErrorV1, +} + +impl PointwiseSelectionFailureV1 +where + Evaluation: JointPointEvaluatorV1, + Observation: JointObservationV1, +{ + pub(crate) const fn feasible( + &self, + ) -> &PointwiseNonEmptyFeasibleJointTuplesV1 { + &self.feasible + } + + pub(crate) const fn policy(&self) -> &DeclaredTotalOrderV1 { + &self.policy + } + + pub(crate) const fn reason(&self) -> SelectionPolicyErrorV1 { + self.reason + } + + pub(crate) fn into_parts( + self, + ) -> ( + PointwiseNonEmptyFeasibleJointTuplesV1, + DeclaredTotalOrderV1, + SelectionPolicyErrorV1, + ) { + (self.feasible, self.policy, self.reason) } } /// Полный client-declared tie-break. Он не участвует в measurement/report. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct DeclaredTotalOrderV1 { - order: Box<[CandidateOrdinalV1]>, + order: Vec, + domain: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct DeclaredPolicyDomainEntryV1 { + ordinal: CandidateOrdinalV1, + rank: usize, } impl DeclaredTotalOrderV1 { @@ -855,33 +984,60 @@ impl DeclaredTotalOrderV1 { if order.len() != candidates.candidates.len() { return Err(SelectionPolicyErrorV1::NotATotalOrder); } - let mut canonical = order.clone(); - canonical.sort_unstable(); - for pair in canonical.windows(2) { - if pair[0] == pair[1] { - return Err(SelectionPolicyErrorV1::DuplicateOrdinal(pair[0])); - } + let mut domain = Vec::new(); + domain + .try_reserve_exact(candidates.candidates.len()) + .map_err(|_| SelectionPolicyErrorV1::ResourceExhausted)?; + domain.extend( + order + .iter() + .copied() + .enumerate() + .map(|(rank, ordinal)| DeclaredPolicyDomainEntryV1 { ordinal, rank }), + ); + domain.sort_unstable_by_key(|entry| entry.ordinal); + if let Some(pair) = domain + .windows(2) + .find(|pair| pair[0].ordinal == pair[1].ordinal) + { + return Err(SelectionPolicyErrorV1::DuplicateOrdinal(pair[0].ordinal)); } - if canonical.iter().copied().ne(candidates + if domain.iter().map(|entry| entry.ordinal).ne(candidates .candidates .iter() .map(|candidate| candidate.ordinal)) { return Err(SelectionPolicyErrorV1::NotATotalOrder); } - Ok(Self { - order: order.into_boxed_slice(), - }) + Ok(Self { order, domain }) + } + + pub(crate) fn order(&self) -> &[CandidateOrdinalV1] { + &self.order + } + + pub(crate) fn into_order(self) -> Vec { + self.order + } + + fn is_bound_to(&self, candidates: &JointCandidateSetV1) -> bool { + self.domain.iter().map(|entry| entry.ordinal).eq(candidates + .candidates + .iter() + .map(|candidate| candidate.ordinal)) } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum SelectionPolicyErrorV1 { + ResourceExhausted, DuplicateOrdinal(CandidateOrdinalV1), NotATotalOrder, + CandidateDomainMismatch, + InternalInvariant, } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, PartialEq)] pub(crate) struct PointwiseSelectedJointTupleV1 where Evaluation: JointPointEvaluatorV1, @@ -910,9 +1066,10 @@ where // A revision-bound report can enter this consuming chain only through // `evaluate_revision_bound` with a Session-minted linear permit. Static // reports have a different concrete observation type. - self.report + let root_binding = self + .report .program - .validate_observation(&self.report.observation) + .bind_observation_surface(&self.report.observation) .map_err(|_| PointwiseSelectedRecheckErrorV1::InvariantDrift)?; let cases = self.report.observation.case_count(); let (execution_count, cell_count) = @@ -924,6 +1081,7 @@ where .execute( core::slice::from_ref(&self.candidate), &self.report.observation, + root_binding, execution_count, cell_count, ) @@ -939,15 +1097,18 @@ where PointwiseSelectedRecheckErrorV1::InvariantDrift } })?; - if let Some(violation) = matrices + if let Some(violation_index) = matrices .cells .iter() - .find(|cell| !cell.decision.is_pass()) - .cloned() + .position(|cell| !cell.decision.is_pass()) { - return Err(PointwiseSelectedRecheckErrorV1::Violation(Box::new( - violation, - ))); + return Err(PointwiseSelectedRecheckErrorV1::Violation { + evidence: PointwiseFreshJointRecheckV1 { + executions: matrices.executions, + cells: matrices.cells, + }, + violation_index, + }); } Ok(PointwiseVerifiedSelectionV1 { selected: self, @@ -959,7 +1120,7 @@ where } } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, PartialEq)] pub(crate) enum PointwiseSelectedRecheckErrorV1 where Evaluation: JointPointEvaluatorV1, @@ -967,19 +1128,22 @@ where ResourceExhausted, InvariantDrift, Evaluator(Evaluation::Error), - Violation(Box>), + Violation { + evidence: PointwiseFreshJointRecheckV1, + violation_index: usize, + }, } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, PartialEq)] pub(crate) struct PointwiseFreshJointRecheckV1 where Evaluation: JointPointEvaluatorV1, { - executions: Box<[JointExecutionRecordV1]>, - cells: Box<[PointwiseJointConstraintCellV1]>, + executions: Vec, + cells: Vec>, } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, PartialEq)] pub(crate) struct PointwiseVerifiedSelectionV1 where Evaluation: JointPointEvaluatorV1, diff --git a/crates/labcolors-core/src/joint_tests.rs b/crates/labcolors-core/src/joint_tests.rs index 992f61b2..aef33acc 100644 --- a/crates/labcolors-core/src/joint_tests.rs +++ b/crates/labcolors-core/src/joint_tests.rs @@ -1,20 +1,20 @@ use crate::Srgb8; use crate::appearance::{EncodedPointPaintV1, PaintId, SurfaceInputPortId}; use crate::composition::AdmittedOpacityV1; -use crate::constraints::{HardDecision, Wcag22Srgb8V1}; +use crate::constraints::{ExactSrgb8IdentityV1, HardDecision, Wcag22Srgb8V1}; use crate::joint::{ - CandidateOrdinalV1, CandidateSetErrorV1, DeclaredTotalOrderV1, HardFeasibilityV1, - JointCandidateSetV1, JointCandidateTupleV1, JointConstraintDecisionV1, JointConstraintIdV1, - JointHardConstraintV1, JointPointEvaluatorV1, JointPointProgramIdentityV1, JointPointProgramV1, - JointProgramErrorV1, JointReportErrorV1, JointVisibleTargetV1, PointwiseHardFeasibilityV1, - PointwiseJointHardConstraintV1, PointwiseJointPointProgramV1, PointwiseJointReportErrorV1, - PointwiseSelectedRecheckErrorV1, SelectionPolicyErrorV1, checked_joint_cardinality, + CandidateOrdinalV1, CandidateSetErrorV1, DeclaredTotalOrderV1, JointCandidateSetV1, + JointCandidateTupleV1, JointConstraintIdV1, JointPointEvaluatorV1, JointPointProgramIdentityV1, + JointProgramErrorV1, JointVisibleTargetV1, PointwiseHardFeasibilityV1, + PointwiseJointConstraintDecisionV1, PointwiseJointHardConstraintV1, + PointwiseJointPointProgramV1, PointwiseJointReportErrorV1, PointwiseSelectedRecheckErrorV1, + SelectionPolicyErrorV1, checked_joint_cardinality, }; use crate::observation::{ ObservationHeadViewV1, ObservationOwnerV1, ObservationPayloadInput, ObservationStreamId, ObservationUpdateInput, ObservedScenarioSetInput, PreparedObservationUpdateV1, Revision, RevisionBoundObservationV1, ScenarioId, ScenarioInput, SurfaceInputBinding, - prepare_observation, + canonicalize_observation_schema, prepare_observation, }; use crate::session::SessionObservationBindingPermitV1; use crate::wcag22::Wcag22CriterionV1; @@ -58,20 +58,22 @@ fn candidates(values: Vec) -> JointCandidateSetV1 { JointCandidateSetV1::new(values).unwrap() } -fn program(constraints: Vec) -> JointPointProgramV1 { - JointPointProgramV1::new(ROOT, LOWER, UPPER, constraints).unwrap() +fn program( + constraints: Vec>, +) -> PointwiseJointPointProgramV1 { + PointwiseJointPointProgramV1::new(ROOT, LOWER, UPPER, constraints).unwrap() } -fn exact_upper(id: u32, target: [u8; 3]) -> JointHardConstraintV1 { - JointHardConstraintV1::exact( +fn exact_upper(id: u32, target: [u8; 3]) -> PointwiseJointHardConstraintV1 { + PointwiseJointHardConstraintV1::exact( JointConstraintIdV1::new(id), JointVisibleTargetV1::Upper, Srgb8::new(target), ) } -fn exact_lower(id: u32, target: [u8; 3]) -> JointHardConstraintV1 { - JointHardConstraintV1::exact( +fn exact_lower(id: u32, target: [u8; 3]) -> PointwiseJointHardConstraintV1 { + PointwiseJointHardConstraintV1::exact( JointConstraintIdV1::new(id), JointVisibleTargetV1::Lower, Srgb8::new(target), @@ -80,10 +82,11 @@ fn exact_lower(id: u32, target: [u8; 3]) -> JointHardConstraintV1 { fn observation(revision: u64, cases: Vec<(u32, [u8; 3])>) -> RevisionBoundObservationV1 { let mut owner = EmptyObservationOwner; + let schema = canonicalize_observation_schema(vec![ROOT]).unwrap(); let prepared = prepare_observation( &mut owner, STREAM, - &[ROOT], + &schema, ObservationUpdateInput { stream: STREAM, revision: Revision::new(revision), @@ -109,6 +112,46 @@ fn observation(revision: u64, cases: Vec<(u32, [u8; 3])>) -> RevisionBoundObserv observation } +fn observation_with_unrelated_surface( + revision: u64, + unrelated: [u8; 3], + root: [u8; 3], +) -> RevisionBoundObservationV1 { + let unrelated_surface = SurfaceInputPortId::new(ROOT.value() - 1); + let mut owner = EmptyObservationOwner; + let schema = canonicalize_observation_schema(vec![ROOT, unrelated_surface]).unwrap(); + let prepared = prepare_observation( + &mut owner, + STREAM, + &schema, + ObservationUpdateInput { + stream: STREAM, + revision: Revision::new(revision), + payload: ObservationPayloadInput::Scenarios(ObservedScenarioSetInput { + scenarios: vec![ScenarioInput { + id: ScenarioId::new(17), + bindings: vec![ + SurfaceInputBinding { + port: ROOT, + value: Srgb8::new(root), + }, + SurfaceInputBinding { + port: unrelated_surface, + value: Srgb8::new(unrelated), + }, + ], + }], + }), + }, + ) + .unwrap(); + let PreparedObservationUpdateV1::Observed(prepared) = prepared else { + panic!("fresh observed update must prepare an observation"); + }; + let (_owner, observation) = prepared.into_parts(); + observation +} + #[test] fn linked_candidate_is_selected_only_after_upper_sees_lower_visible_surface() { let observed = observation(1, vec![(1, [0; 3])]); @@ -145,14 +188,14 @@ fn linked_candidate_is_selected_only_after_upper_sees_lower_visible_surface() { assert_eq!(report.cells()[0].decision().target(), Srgb8::new([192; 3])); assert!(matches!( report.cells()[0].decision(), - JointConstraintDecisionV1::Violation(_) + PointwiseJointConstraintDecisionV1::Violation(_) )); assert!(matches!( report.cells()[1].decision(), - JointConstraintDecisionV1::Pass(_) + PointwiseJointConstraintDecisionV1::Pass(_) )); - let HardFeasibilityV1::NonEmpty(feasible) = report.classify() else { + let PointwiseHardFeasibilityV1::NonEmpty(feasible) = report.classify() else { panic!("second joint tuple must be feasible"); }; assert_eq!(feasible.feasible(), &[CandidateOrdinalV1::new(1)]); @@ -161,7 +204,7 @@ fn linked_candidate_is_selected_only_after_upper_sees_lower_visible_surface() { vec![CandidateOrdinalV1::new(0), CandidateOrdinalV1::new(1)], ) .unwrap(); - let selected = feasible.select(policy); + let selected = feasible.select(policy).unwrap(); assert_eq!(selected.ordinal(), CandidateOrdinalV1::new(1)); let verified = selected.recheck().unwrap(); assert_eq!(verified.ordinal(), CandidateOrdinalV1::new(1)); @@ -181,7 +224,7 @@ fn every_unique_physical_case_must_pass_without_worst_or_average_reduction() { assert_eq!(report.cells().len(), 2); assert_eq!(report.cells()[0].decision().actual(), Srgb8::new([128; 3])); assert_eq!(report.cells()[1].decision().actual(), Srgb8::new([192; 3])); - let HardFeasibilityV1::Infeasible(report) = report.classify() else { + let PointwiseHardFeasibilityV1::Infeasible(report) = report.classify() else { panic!("one violated case must exclude the whole tuple"); }; assert_eq!( @@ -275,6 +318,25 @@ fn scenario_declaration_permutation_is_canonical() { assert_eq!(first, second); } +#[test] +fn revision_bound_root_uses_its_schema_ordinal_and_retains_case_provenance() { + let report = program(vec![exact_lower(1, [128; 3])]) + .evaluate_revision_bound( + candidates(vec![candidate(0, ([0; 3], 0.5), ([255; 3], 1.0))]), + observation_with_unrelated_surface(6, [0; 3], [255; 3]), + session_permit(), + ) + .unwrap(); + + assert_eq!(report.executions().len(), 1); + assert_eq!(report.executions()[0].lower_visible(), Srgb8::new([128; 3])); + assert_eq!(report.provenance(0), Some(&[ScenarioId::new(17)][..])); + assert!(matches!( + report.cells()[0].decision(), + PointwiseJointConstraintDecisionV1::Pass(_) + )); +} + #[test] fn static_joint_evaluation_is_explicitly_lifecycle_free() { let report = program(vec![exact_upper(1, [255; 3])]) @@ -301,7 +363,7 @@ fn static_joint_key_mismatch_fails_before_compositing() { assert!(matches!( result, - Err(JointReportErrorV1::MissingRootSurface(ROOT)) + Err(PointwiseJointReportErrorV1::MissingRootSurface(ROOT)) )); assert_eq!(crate::composition::source_over_evaluation_count(), 0); } @@ -340,7 +402,7 @@ fn declared_policy_is_separate_from_report_and_is_the_only_tie_break() { .unwrap() }; - let HardFeasibilityV1::NonEmpty(first) = make_report().classify() else { + let PointwiseHardFeasibilityV1::NonEmpty(first) = make_report().classify() else { panic!("both tuples must pass"); }; let first_policy = DeclaredTotalOrderV1::new( @@ -348,7 +410,7 @@ fn declared_policy_is_separate_from_report_and_is_the_only_tie_break() { vec![CandidateOrdinalV1::new(7), CandidateOrdinalV1::new(4)], ) .unwrap(); - let HardFeasibilityV1::NonEmpty(second) = make_report().classify() else { + let PointwiseHardFeasibilityV1::NonEmpty(second) = make_report().classify() else { panic!("both tuples must pass"); }; let second_policy = DeclaredTotalOrderV1::new( @@ -357,15 +419,168 @@ fn declared_policy_is_separate_from_report_and_is_the_only_tie_break() { ) .unwrap(); assert_eq!( - first.select(first_policy).ordinal(), + first.select(first_policy).unwrap().ordinal(), CandidateOrdinalV1::new(7) ); assert_eq!( - second.select(second_policy).ordinal(), + second.select(second_policy).unwrap().ordinal(), CandidateOrdinalV1::new(4) ); } +#[test] +fn foreign_disjoint_and_partially_overlapping_policy_domains_are_typed_errors() { + let make_actual = || { + let report = program(vec![]) + .evaluate_revision_bound( + candidates(vec![ + candidate(1, ([11; 3], 1.0), ([111; 3], 1.0)), + candidate(2, ([22; 3], 1.0), ([122; 3], 1.0)), + ]), + observation(70, vec![(1, [0; 3])]), + session_permit(), + ) + .unwrap(); + let PointwiseHardFeasibilityV1::NonEmpty(feasible) = report.classify() else { + panic!("an unconstrained nonempty domain must be feasible"); + }; + feasible + }; + + let disjoint_domain = candidates(vec![ + candidate(10, ([10; 3], 1.0), ([210; 3], 1.0)), + candidate(11, ([11; 3], 1.0), ([211; 3], 1.0)), + ]); + let disjoint_policy = DeclaredTotalOrderV1::new( + &disjoint_domain, + vec![CandidateOrdinalV1::new(10), CandidateOrdinalV1::new(11)], + ) + .unwrap(); + let disjoint_feasible = make_actual(); + crate::composition::reset_source_over_evaluation_count(); + let (disjoint_failure, disjoint_allocations) = + crate::test_support::measured_allocations(|| { + match disjoint_feasible.select(disjoint_policy) { + Ok(_) => panic!("a disjoint policy domain must be rejected"), + Err(failure) => failure, + } + }); + assert_eq!( + disjoint_failure.reason(), + SelectionPolicyErrorV1::CandidateDomainMismatch + ); + assert_eq!( + disjoint_failure.feasible().feasible(), + &[CandidateOrdinalV1::new(1), CandidateOrdinalV1::new(2)] + ); + assert_eq!( + disjoint_failure.policy().order(), + &[CandidateOrdinalV1::new(10), CandidateOrdinalV1::new(11)] + ); + assert_eq!(disjoint_allocations, 0); + assert_eq!(crate::composition::source_over_evaluation_count(), 0); + + // Ordinal 1 overlaps and is first in the foreign order. The old selector + // silently accepted it; exact-domain validation must reject the policy. + let partial_domain = candidates(vec![ + candidate(1, ([31; 3], 1.0), ([131; 3], 1.0)), + candidate(3, ([33; 3], 1.0), ([133; 3], 1.0)), + ]); + let partial_policy = DeclaredTotalOrderV1::new( + &partial_domain, + vec![CandidateOrdinalV1::new(1), CandidateOrdinalV1::new(3)], + ) + .unwrap(); + let partial_feasible = make_actual(); + crate::composition::reset_source_over_evaluation_count(); + let (partial_failure, partial_allocations) = crate::test_support::measured_allocations(|| { + match partial_feasible.select(partial_policy) { + Ok(_) => panic!("a partially overlapping policy domain must be rejected"), + Err(failure) => failure, + } + }); + assert_eq!( + partial_failure.reason(), + SelectionPolicyErrorV1::CandidateDomainMismatch + ); + assert_eq!(partial_allocations, 0); + assert_eq!(crate::composition::source_over_evaluation_count(), 0); + + // Recover the expensive report and the caller's original Vec allocation, + // repair only the order, then retry without re-running physical evaluation. + let (recovered_feasible, rejected_policy, reason) = partial_failure.into_parts(); + assert_eq!(reason, SelectionPolicyErrorV1::CandidateDomainMismatch); + let order_backing = rejected_policy.order().as_ptr(); + let mut corrected_order = rejected_policy.into_order(); + corrected_order[1] = CandidateOrdinalV1::new(2); + corrected_order.swap(0, 1); + assert_eq!(corrected_order.as_ptr(), order_backing); + let corrected_policy = + DeclaredTotalOrderV1::new(recovered_feasible.candidate_set(), corrected_order).unwrap(); + assert_eq!(corrected_policy.order().as_ptr(), order_backing); + let selected = recovered_feasible.select(corrected_policy).unwrap(); + assert_eq!(selected.ordinal(), CandidateOrdinalV1::new(2)); + assert_eq!(crate::composition::source_over_evaluation_count(), 0); +} + +#[test] +fn policy_is_reusable_for_the_same_ordinal_domain_without_owning_candidate_physics() { + let make_actual = || { + let report = program(vec![]) + .evaluate_revision_bound( + candidates(vec![ + candidate(1, ([11; 3], 1.0), ([111; 3], 1.0)), + candidate(2, ([22; 3], 1.0), ([222; 3], 1.0)), + ]), + observation(71, vec![(1, [0; 3])]), + session_permit(), + ) + .unwrap(); + let PointwiseHardFeasibilityV1::NonEmpty(feasible) = report.classify() else { + panic!("an unconstrained nonempty domain must be feasible"); + }; + feasible + }; + + let different_physics = candidates(vec![ + candidate(2, ([202; 3], 0.5), ([72; 3], 1.0)), + candidate(1, ([201; 3], 0.5), ([71; 3], 1.0)), + ]); + let reusable_policy = DeclaredTotalOrderV1::new( + &different_physics, + vec![CandidateOrdinalV1::new(2), CandidateOrdinalV1::new(1)], + ) + .unwrap(); + let verified = make_actual() + .select(reusable_policy) + .unwrap() + .recheck() + .unwrap(); + assert_eq!(verified.ordinal(), CandidateOrdinalV1::new(2)); + assert_eq!( + verified.fresh_executions()[0].lower_visible(), + Srgb8::new([22; 3]) + ); + assert_eq!( + verified.fresh_executions()[0].upper_visible(), + Srgb8::new([222; 3]) + ); + + let independently_identical = candidates(vec![ + candidate(2, ([22; 3], 1.0), ([222; 3], 1.0)), + candidate(1, ([11; 3], 1.0), ([111; 3], 1.0)), + ]); + let identical_policy = DeclaredTotalOrderV1::new( + &independently_identical, + vec![CandidateOrdinalV1::new(1), CandidateOrdinalV1::new(2)], + ) + .unwrap(); + assert_eq!( + make_actual().select(identical_policy).unwrap().ordinal(), + CandidateOrdinalV1::new(1) + ); +} + #[test] fn fresh_recheck_executes_the_selected_joint_program_again_on_the_same_revision() { let observed = observation(8, vec![(1, [0; 3]), (2, [255; 3])]); @@ -377,13 +592,13 @@ fn fresh_recheck_executes_the_selected_joint_program_again_on_the_same_revision( ) .unwrap(); crate::composition::reset_source_over_evaluation_count(); - let HardFeasibilityV1::NonEmpty(feasible) = report.classify() else { + let PointwiseHardFeasibilityV1::NonEmpty(feasible) = report.classify() else { panic!("opaque upper must pass on both roots"); }; let policy = DeclaredTotalOrderV1::new(feasible.candidate_set(), vec![CandidateOrdinalV1::new(0)]) .unwrap(); - let selected = feasible.select(policy); + let selected = feasible.select(policy).unwrap(); let verified = selected.recheck().unwrap(); assert_eq!(crate::composition::source_over_evaluation_count(), 4); @@ -403,14 +618,14 @@ fn empty_hard_constraint_set_is_non_vacuously_feasible() { assert_eq!(report.executions().len(), 1); assert!(report.cells().is_empty()); - let HardFeasibilityV1::NonEmpty(feasible) = report.classify() else { + let PointwiseHardFeasibilityV1::NonEmpty(feasible) = report.classify() else { panic!("a tuple with no hard violations must be feasible"); }; assert_eq!(feasible.feasible(), &[CandidateOrdinalV1::new(0)]); let policy = DeclaredTotalOrderV1::new(feasible.candidate_set(), vec![CandidateOrdinalV1::new(0)]) .unwrap(); - let verified = feasible.select(policy).recheck().unwrap(); + let verified = feasible.select(policy).unwrap().recheck().unwrap(); assert_eq!(verified.fresh_executions().len(), 1); assert!(verified.fresh_cells().is_empty()); } @@ -451,7 +666,7 @@ fn generic_wcag_evaluator_can_constrain_the_derived_lower_occurrence() { vec![CandidateOrdinalV1::new(1), CandidateOrdinalV1::new(2)], ) .unwrap(); - let verified = feasible.select(policy).recheck().unwrap(); + let verified = feasible.select(policy).unwrap().recheck().unwrap(); assert_eq!(verified.ordinal(), CandidateOrdinalV1::new(2)); assert_eq!(verified.fresh_cells().len(), 1); assert!(verified.fresh_cells()[0].decision().is_pass()); @@ -535,7 +750,7 @@ fn evaluator_error_invalidates_the_full_report_and_fresh_recheck() { DeclaredTotalOrderV1::new(feasible.candidate_set(), vec![CandidateOrdinalV1::new(0)]) .unwrap(); assert!(matches!( - feasible.select(policy).recheck(), + feasible.select(policy).unwrap().recheck(), Err(PointwiseSelectedRecheckErrorV1::Evaluator( "evaluator-fault" )) @@ -568,7 +783,7 @@ fn invalid_domains_and_policies_fail_before_compositing() { }) ); assert_eq!( - JointPointProgramV1::new(ROOT, LOWER, LOWER, vec![exact_upper(1, [0; 3])]), + PointwiseJointPointProgramV1::new(ROOT, LOWER, LOWER, vec![exact_upper(1, [0; 3])]), Err(JointProgramErrorV1::SamePaintIdentity(LOWER)) ); let observed = observation(9, vec![(1, [0; 3])]); @@ -585,7 +800,7 @@ fn invalid_domains_and_policies_fail_before_compositing() { observed, session_permit() ), - Err(JointReportErrorV1::CandidatePaintMismatch { + Err(PointwiseJointReportErrorV1::CandidatePaintMismatch { stage: JointVisibleTargetV1::Lower, .. }) @@ -606,14 +821,56 @@ fn invalid_domains_and_policies_fail_before_compositing() { ); } +#[test] +fn duplicate_physical_detection_preserves_canonical_ordinal_precedence() { + let first_order = vec![ + candidate(4, ([0; 3], 1.0), ([10; 3], 1.0)), + candidate(9, ([250; 3], 1.0), ([240; 3], 1.0)), + candidate(3, ([0; 3], 1.0), ([10; 3], 1.0)), + candidate(1, ([250; 3], 1.0), ([240; 3], 1.0)), + ]; + let reverse_order = first_order.iter().rev().copied().collect(); + let expected = Err(CandidateSetErrorV1::DuplicatePhysicalTuple { + first: CandidateOrdinalV1::new(1), + second: CandidateOrdinalV1::new(9), + }); + assert_eq!(JointCandidateSetV1::new(first_order), expected); + assert_eq!(JointCandidateSetV1::new(reverse_order), expected); + + assert_eq!( + JointCandidateSetV1::new(vec![ + candidate(7, ([70; 3], 0.5), ([170; 3], 1.0)), + candidate(2, ([70; 3], 0.5), ([170; 3], 1.0)), + candidate(5, ([70; 3], 0.5), ([170; 3], 1.0)), + ]), + Err(CandidateSetErrorV1::DuplicatePhysicalTuple { + first: CandidateOrdinalV1::new(2), + second: CandidateOrdinalV1::new(5), + }) + ); +} + +#[test] +fn large_candidate_domain_remains_ordinal_canonical() { + const COUNT: u32 = 4_096; + let make = |ordinal: u32| { + let bytes = [(ordinal >> 8) as u8, ordinal as u8, 17]; + candidate(ordinal, (bytes, 1.0), ([255, 0, 19], 1.0)) + }; + let input = (0..COUNT).rev().map(make).collect(); + let expected: Vec<_> = (0..COUNT).map(make).collect(); + let domain = JointCandidateSetV1::new(input).unwrap(); + assert_eq!(domain.candidates(), expected); +} + #[test] fn cardinality_overflow_is_rejected_by_preflight() { assert_eq!( checked_joint_cardinality(usize::MAX, 2, 1), - Err(JointReportErrorV1::ResourceExhausted) + Err(PointwiseJointReportErrorV1::ResourceExhausted) ); assert_eq!( checked_joint_cardinality(usize::MAX / 2 + 1, 2, 2), - Err(JointReportErrorV1::ResourceExhausted) + Err(PointwiseJointReportErrorV1::ResourceExhausted) ); } diff --git a/crates/labcolors-core/src/lcs_occurrence.rs b/crates/labcolors-core/src/lcs_occurrence.rs new file mode 100644 index 00000000..bc051d57 --- /dev/null +++ b/crates/labcolors-core/src/lcs_occurrence.rs @@ -0,0 +1,835 @@ +//! Type foundation for a context-bound Labpics Colors Space occurrence. +//! +//! Encoded output, a framed tristimulus, immutable appearance context and +//! separately named derived views are different values. Executable transforms +//! are sealed and versioned: encoded sRGB8 lowers through the existing IEC +//! transfer table and XYZ(D65) matrix, then an occurrence can derive independent +//! rectangular Oklab and contextual CAM16 views. These are deterministic model +//! derivations, not evidence that a host rendered or a person observed the +//! result. In particular, an occurrence has no inverse operation accepting an +//! arbitrary second context. + +use crate::Srgb8; +use crate::spaces::cam16::forward_correlates_v1; +use crate::spaces::oklab::xyz_d65_to_oklab_v1; +use crate::spaces::srgb::xyz_d65_from_srgb8_v1; +use crate::spaces::vc::{Cam16SurroundV1, ViewingConditions}; + +/// A registered encoded-output domain. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum OutputProfileId { + Iec61966Srgb8D65V1, +} + +/// Exact encoded channels plus the output profile which gives them meaning. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ColorSignal { + srgb8: Srgb8, + output_profile: OutputProfileId, +} + +impl ColorSignal { + /// Form the only admitted encoded signal without accepting a free-form + /// channel/profile pairing. + pub(crate) const fn from_srgb8(srgb8: Srgb8) -> Self { + Self { + srgb8, + output_profile: OutputProfileId::Iec61966Srgb8D65V1, + } + } + + pub(crate) const fn srgb8(self) -> Srgb8 { + self.srgb8 + } + + pub(crate) const fn output_profile(self) -> OutputProfileId { + self.output_profile + } +} + +/// Exact code release for one colorimetric signal-to-tristimulus transform. +/// +/// This is not a composition profile, renderer capability or observation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ColorimetricTransformReleaseId { + Iec61966Srgb8ToCie1931TwoDegreeXyzD65RelativeY1V1, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ObserverProfileId { + Cie1931TwoDegreeV1, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ReferenceWhiteId { + Iec61966D65ChromaticityV1, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum TristimulusScale { + RelativeY1, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ColorimetricFrameReleaseId { + XyzV1, + #[cfg(test)] + MutationSentinelV1, +} + +/// Everything required to interpret one XYZ triple. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ColorimetricFrameId { + observer: ObserverProfileId, + reference_white: ReferenceWhiteId, + scale: TristimulusScale, + release: ColorimetricFrameReleaseId, +} + +impl ColorimetricFrameId { + const fn registered( + observer: ObserverProfileId, + reference_white: ReferenceWhiteId, + scale: TristimulusScale, + release: ColorimetricFrameReleaseId, + ) -> Self { + Self { + observer, + reference_white, + scale, + release, + } + } + + pub(crate) const fn observer(self) -> ObserverProfileId { + self.observer + } + + pub(crate) const fn reference_white(self) -> ReferenceWhiteId { + self.reference_white + } + + pub(crate) const fn scale(self) -> TristimulusScale { + self.scale + } + + pub(crate) const fn release(self) -> ColorimetricFrameReleaseId { + self.release + } +} + +/// Canonical result frame of the registered encoded-sRGB8 transform. +pub(crate) const IEC_SRGB_D65_XYZ_FRAME_V1: ColorimetricFrameId = ColorimetricFrameId::registered( + ObserverProfileId::Cie1931TwoDegreeV1, + ReferenceWhiteId::Iec61966D65ChromaticityV1, + TristimulusScale::RelativeY1, + ColorimetricFrameReleaseId::XyzV1, +); + +#[cfg(test)] +pub(crate) const MUTATION_SENTINEL_XYZ_FRAME_V1: ColorimetricFrameId = + ColorimetricFrameId::registered( + ObserverProfileId::Cie1931TwoDegreeV1, + ReferenceWhiteId::Iec61966D65ChromaticityV1, + TristimulusScale::RelativeY1, + ColorimetricFrameReleaseId::MutationSentinelV1, + ); + +/// The one closed, code-owned binding admitted by the current F0 slice. +/// +/// A variant is the tuple: independent profile, transform and frame fields +/// cannot be authored or mixed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum AdmittedSrgb8TristimulusBindingV1 { + Iec61966Srgb8ToCie1931TwoDegreeXyzD65RelativeY1V1, +} + +impl AdmittedSrgb8TristimulusBindingV1 { + pub(crate) const fn signal_output_profile(self) -> OutputProfileId { + match self { + Self::Iec61966Srgb8ToCie1931TwoDegreeXyzD65RelativeY1V1 => { + OutputProfileId::Iec61966Srgb8D65V1 + } + } + } + + pub(crate) const fn transform_release(self) -> ColorimetricTransformReleaseId { + match self { + Self::Iec61966Srgb8ToCie1931TwoDegreeXyzD65RelativeY1V1 => { + ColorimetricTransformReleaseId::Iec61966Srgb8ToCie1931TwoDegreeXyzD65RelativeY1V1 + } + } + } + + pub(crate) const fn result_frame(self) -> ColorimetricFrameId { + match self { + Self::Iec61966Srgb8ToCie1931TwoDegreeXyzD65RelativeY1V1 => IEC_SRGB_D65_XYZ_FRAME_V1, + } + } +} + +pub(crate) const ADMITTED_SRGB8_TRISTIMULUS_BINDING_V1: AdmittedSrgb8TristimulusBindingV1 = + AdmittedSrgb8TristimulusBindingV1::Iec61966Srgb8ToCie1931TwoDegreeXyzD65RelativeY1V1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NumericDomainError { + NonFinite, + Negative, + NotPositive, + AboveOne, + HueOutOfRange, +} + +/// Finite, non-negative binary64 value with canonical positive zero. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +struct FiniteNonNegative(u64); + +impl FiniteNonNegative { + fn new(value: f64) -> Result { + if !value.is_finite() { + return Err(NumericDomainError::NonFinite); + } + if value < 0.0 { + return Err(NumericDomainError::Negative); + } + Ok(Self(if value == 0.0 { + 0.0_f64.to_bits() + } else { + value.to_bits() + })) + } + + fn get(self) -> f64 { + f64::from_bits(self.0) + } +} + +/// One finite XYZ point plus the identity of its colorimetric frame. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct TristimulusSample { + xyz: [FiniteNonNegative; 3], + frame: ColorimetricFrameId, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum TristimulusComponentV1 { + X, + Y, + Z, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TristimulusDomainErrorV1 { + component: TristimulusComponentV1, + reason: NumericDomainError, +} + +impl TristimulusDomainErrorV1 { + pub(crate) const fn component(self) -> TristimulusComponentV1 { + self.component + } + + pub(crate) const fn reason(self) -> NumericDomainError { + self.reason + } +} + +impl TristimulusSample { + fn try_from_registered_xyz( + xyz: [f64; 3], + frame: ColorimetricFrameId, + ) -> Result { + let admit = |value, component| { + FiniteNonNegative::new(value) + .map_err(|reason| TristimulusDomainErrorV1 { component, reason }) + }; + Ok(Self { + xyz: [ + admit(xyz[0], TristimulusComponentV1::X)?, + admit(xyz[1], TristimulusComponentV1::Y)?, + admit(xyz[2], TristimulusComponentV1::Z)?, + ], + frame, + }) + } + + #[cfg(test)] + pub(crate) fn try_from_xyz_for_test( + xyz: [f64; 3], + frame: ColorimetricFrameId, + ) -> Result { + Self::try_from_registered_xyz(xyz, frame) + } + + pub(crate) fn xyz(self) -> [f64; 3] { + self.xyz.map(FiniteNonNegative::get) + } + + pub(crate) const fn frame(self) -> ColorimetricFrameId { + self.frame + } +} + +/// Content-bound provenance of one deterministic modeled transform. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ModeledTristimulusProvenanceV1 { + source_signal: ColorSignal, + binding: AdmittedSrgb8TristimulusBindingV1, +} + +impl ModeledTristimulusProvenanceV1 { + pub(crate) const fn source_signal(self) -> ColorSignal { + self.source_signal + } + + pub(crate) const fn binding(self) -> AdmittedSrgb8TristimulusBindingV1 { + self.binding + } +} + +/// Replayable ideal colorimetric derivation under one admitted binding. +/// +/// This is not a renderer capability, render observation, human observation or +/// certified field bound. It cannot by itself satisfy such predicates. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ModeledTristimulusDerivationV1 { + sample: TristimulusSample, + provenance: ModeledTristimulusProvenanceV1, +} + +impl ModeledTristimulusDerivationV1 { + pub(crate) const fn sample(self) -> TristimulusSample { + self.sample + } + + pub(crate) const fn provenance(self) -> ModeledTristimulusProvenanceV1 { + self.provenance + } + + pub(crate) fn replay(self) -> Result { + derive_sample_with_binding(self.provenance.source_signal, self.provenance.binding) + } +} + +fn admitted_binding(output_profile: OutputProfileId) -> AdmittedSrgb8TristimulusBindingV1 { + match output_profile { + OutputProfileId::Iec61966Srgb8D65V1 => ADMITTED_SRGB8_TRISTIMULUS_BINDING_V1, + } +} + +fn derive_sample_with_binding( + signal: ColorSignal, + binding: AdmittedSrgb8TristimulusBindingV1, +) -> Result { + let xyz = match ( + signal.output_profile(), + binding.signal_output_profile(), + binding.transform_release(), + ) { + ( + OutputProfileId::Iec61966Srgb8D65V1, + OutputProfileId::Iec61966Srgb8D65V1, + ColorimetricTransformReleaseId::Iec61966Srgb8ToCie1931TwoDegreeXyzD65RelativeY1V1, + ) => xyz_d65_from_srgb8_v1(signal.srgb8()), + }; + TristimulusSample::try_from_registered_xyz(xyz, binding.result_frame()) +} + +/// Derive one modeled tristimulus from an encoded sRGB8 point. +/// +/// Composition provenance remains the responsibility of the upstream +/// render/composition layer that produced the signal; renderer capability and +/// actual observations are deliberately outside this deterministic transform. +pub(crate) fn derive_modeled_tristimulus_v1( + signal: ColorSignal, +) -> Result { + let binding = admitted_binding(signal.output_profile()); + let sample = derive_sample_with_binding(signal, binding)?; + Ok(ModeledTristimulusDerivationV1 { + sample, + provenance: ModeledTristimulusProvenanceV1 { + source_signal: signal, + binding, + }, + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum AppearanceContextSchemaReleaseId { + Ciecam16ViewingInputsV1, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SurroundProfileId { + AverageV1, + DimV1, + DarkV1, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum AppearanceContextFieldV1 { + AdaptingLuminanceCdM2, + BackgroundLuminanceRatio, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AppearanceContextDomainErrorV1 { + field: AppearanceContextFieldV1, + reason: NumericDomainError, +} + +impl AppearanceContextDomainErrorV1 { + pub(crate) const fn field(self) -> AppearanceContextFieldV1 { + self.field + } + + pub(crate) const fn reason(self) -> NumericDomainError { + self.reason + } +} + +/// Finite, strictly positive CIECAM16 adapting luminance in cd/m². +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct AdaptingLuminanceCdM2(FiniteNonNegative); + +impl AdaptingLuminanceCdM2 { + pub(crate) fn try_new(value: f64) -> Result { + let field = AppearanceContextFieldV1::AdaptingLuminanceCdM2; + let value = FiniteNonNegative::new(value) + .map_err(|reason| AppearanceContextDomainErrorV1 { field, reason })?; + if value.get() == 0.0 { + return Err(AppearanceContextDomainErrorV1 { + field, + reason: NumericDomainError::NotPositive, + }); + } + Ok(Self(value)) + } + + pub(crate) fn get(self) -> f64 { + self.0.get() + } +} + +/// Finite CIECAM16 background ratio `Y_b / Y_w` in `(0, 1]`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct BackgroundLuminanceRatio(FiniteNonNegative); + +impl BackgroundLuminanceRatio { + pub(crate) fn try_new(value: f64) -> Result { + let field = AppearanceContextFieldV1::BackgroundLuminanceRatio; + let value = FiniteNonNegative::new(value) + .map_err(|reason| AppearanceContextDomainErrorV1 { field, reason })?; + if value.get() == 0.0 { + return Err(AppearanceContextDomainErrorV1 { + field, + reason: NumericDomainError::NotPositive, + }); + } + if value.get() > 1.0 { + return Err(AppearanceContextDomainErrorV1 { + field, + reason: NumericDomainError::AboveOne, + }); + } + Ok(Self(value)) + } + + pub(crate) fn get(self) -> f64 { + self.0.get() + } +} + +/// Content identity of immutable semantic viewing inputs. +/// +/// Derived CAM constants are intentionally absent and must remain a private +/// cache of the appearance-view implementation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct AppearanceContextId { + schema_release: AppearanceContextSchemaReleaseId, + frame: ColorimetricFrameId, + adapting_luminance_cd_m2: AdaptingLuminanceCdM2, + background_luminance_ratio: BackgroundLuminanceRatio, + surround: SurroundProfileId, +} + +impl AppearanceContextId { + pub(crate) const fn from_inputs( + schema_release: AppearanceContextSchemaReleaseId, + frame: ColorimetricFrameId, + adapting_luminance_cd_m2: AdaptingLuminanceCdM2, + background_luminance_ratio: BackgroundLuminanceRatio, + surround: SurroundProfileId, + ) -> Self { + Self { + schema_release, + frame, + adapting_luminance_cd_m2, + background_luminance_ratio, + surround, + } + } + + pub(crate) const fn schema_release(self) -> AppearanceContextSchemaReleaseId { + self.schema_release + } + + pub(crate) const fn frame(self) -> ColorimetricFrameId { + self.frame + } + + pub(crate) fn adapting_luminance_cd_m2(self) -> f64 { + self.adapting_luminance_cd_m2.get() + } + + pub(crate) fn background_luminance_ratio(self) -> f64 { + self.background_luminance_ratio.get() + } + + pub(crate) const fn surround_profile(self) -> SurroundProfileId { + self.surround + } +} + +/// A finite angle; absence of hue is represented by [`HueState`], never `0°`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct HueAngle(u64); + +impl HueAngle { + pub(crate) fn new(degrees: f64) -> Result { + if !degrees.is_finite() { + return Err(NumericDomainError::NonFinite); + } + if !(0.0..360.0).contains(°rees) { + return Err(NumericDomainError::HueOutOfRange); + } + Ok(Self(if degrees == 0.0 { + 0.0_f64.to_bits() + } else { + degrees.to_bits() + })) + } + + pub(crate) fn degrees(self) -> f64 { + f64::from_bits(self.0) + } +} + +/// Opaque identity of an admitted powerless-hue rule. +/// +/// It has no constructor until a concrete named-view release is registered. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct HuePowerlessProfileId(u32); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum HueState { + Defined(HueAngle), + UndefinedExact, + PowerlessBy(HuePowerlessProfileId), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OccurrenceFormationError { + FrameMismatch { + stimulus: ColorimetricFrameId, + context: ColorimetricFrameId, + }, +} + +/// LCS identity: one tristimulus sample bound to one immutable context. +/// +/// Whether the sample is modeled, renderer-observed or measured belongs to its +/// external provenance; forming this identity does not upgrade that claim. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LcsOccurrence { + sample: TristimulusSample, + context: AppearanceContextId, +} + +impl LcsOccurrence { + /// Bind one sample to one context with an exactly matching frame. + /// + /// Named appearance views are derived later from this pair. No view + /// coordinate is accepted here, so contradictory cached views cannot become + /// part of occurrence identity. + pub(crate) fn in_context( + sample: TristimulusSample, + context: AppearanceContextId, + ) -> Result { + if sample.frame() != context.frame() { + return Err(OccurrenceFormationError::FrameMismatch { + stimulus: sample.frame(), + context: context.frame(), + }); + } + Ok(Self { sample, context }) + } + + pub(crate) const fn sample(self) -> TristimulusSample { + self.sample + } + + pub(crate) const fn context(self) -> AppearanceContextId { + self.context + } +} + +/// Formula and operation-order release of the rectangular Oklab view. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum OklabViewReleaseId { + Ottosson20210125XyzD65V1, +} + +/// Formula and operation-order release of the context-dependent CAM16 view. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Cam16ViewReleaseId { + LiEtAl2017Cie248ForwardV1, +} + +/// Typed release discriminator used only to qualify derivation errors. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum AppearanceViewReleaseIdV1 { + Oklab(OklabViewReleaseId), + Cam16(Cam16ViewReleaseId), +} + +pub(crate) const OKLAB_VIEW_RELEASE_V1: OklabViewReleaseId = + OklabViewReleaseId::Ottosson20210125XyzD65V1; +pub(crate) const CAM16_VIEW_RELEASE_V1: Cam16ViewReleaseId = + Cam16ViewReleaseId::LiEtAl2017Cie248ForwardV1; + +/// Finite binary64 coordinate with canonical positive zero. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +struct FiniteCoordinate(u64); + +impl FiniteCoordinate { + fn new(value: f64) -> Result { + if !value.is_finite() { + return Err(NumericDomainError::NonFinite); + } + Ok(Self(if value == 0.0 { + 0.0_f64.to_bits() + } else { + value.to_bits() + })) + } + + fn get(self) -> f64 { + f64::from_bits(self.0) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum AppearanceViewFieldV1 { + OklabL, + OklabA, + OklabB, + Cam16J, + Cam16Q, + Cam16C, + Cam16M, + Cam16S, + Cam16Hue, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AppearanceStateDerivationErrorV1 { + UnsupportedFrame { + frame: ColorimetricFrameId, + }, + NumericDomain { + release: AppearanceViewReleaseIdV1, + field: AppearanceViewFieldV1, + reason: NumericDomainError, + }, +} + +/// Rectangular Oklab geometry of one admitted XYZ(D65) stimulus. +/// +/// It deliberately has no hue, context-dependent correlate, inverse or setter. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct OklabViewV1 { + release: OklabViewReleaseId, + l: FiniteCoordinate, + a: FiniteCoordinate, + b: FiniteCoordinate, +} + +impl OklabViewV1 { + pub(crate) const fn release(self) -> OklabViewReleaseId { + self.release + } + + pub(crate) fn l(self) -> f64 { + self.l.get() + } + + pub(crate) fn a(self) -> f64 { + self.a.get() + } + + pub(crate) fn b(self) -> f64 { + self.b.get() + } +} + +/// CAM16 appearance correlates of one occurrence under its own context. +/// +/// This view is not CAM16-UCS, a difference calibration or a rendering claim. +/// Its hue is the CAM16 angular correlate only; no Oklab direction enters it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Cam16ViewV1 { + release: Cam16ViewReleaseId, + j: FiniteNonNegative, + q: FiniteNonNegative, + c: FiniteNonNegative, + m: FiniteNonNegative, + s: FiniteNonNegative, + hue: HueState, +} + +impl Cam16ViewV1 { + pub(crate) const fn release(self) -> Cam16ViewReleaseId { + self.release + } + + pub(crate) fn j(self) -> f64 { + self.j.get() + } + + pub(crate) fn q(self) -> f64 { + self.q.get() + } + + pub(crate) fn c(self) -> f64 { + self.c.get() + } + + pub(crate) fn m(self) -> f64 { + self.m.get() + } + + pub(crate) fn s(self) -> f64 { + self.s.get() + } + + pub(crate) const fn hue(self) -> HueState { + self.hue + } +} + +/// One-way, derived appearance snapshot of exactly one occurrence. +/// +/// Canonical LCS identity remains [`LcsOccurrence`] (`sample × context`). This +/// type is only a deterministic cache of separately named views and cannot be +/// constructed from, edited through or inverted from view coordinates. +#[derive(Debug, Clone, Copy)] +pub struct AppearanceState { + occurrence: LcsOccurrence, + oklab: OklabViewV1, + cam16: Cam16ViewV1, +} + +impl AppearanceState { + pub(crate) fn derive_v1( + occurrence: LcsOccurrence, + ) -> Result { + if occurrence.sample().frame() != IEC_SRGB_D65_XYZ_FRAME_V1 { + return Err(AppearanceStateDerivationErrorV1::UnsupportedFrame { + frame: occurrence.sample().frame(), + }); + } + + let oklab = derive_oklab_view_v1(occurrence.sample().xyz())?; + let cam16 = derive_cam16_view_v1(occurrence)?; + Ok(Self { + occurrence, + oklab, + cam16, + }) + } + + pub(crate) const fn occurrence(self) -> LcsOccurrence { + self.occurrence + } + + pub(crate) const fn oklab(self) -> OklabViewV1 { + self.oklab + } + + pub(crate) const fn cam16(self) -> Cam16ViewV1 { + self.cam16 + } +} + +fn view_numeric_error( + release: AppearanceViewReleaseIdV1, + field: AppearanceViewFieldV1, + reason: NumericDomainError, +) -> AppearanceStateDerivationErrorV1 { + AppearanceStateDerivationErrorV1::NumericDomain { + release, + field, + reason, + } +} + +fn derive_oklab_view_v1(xyz: [f64; 3]) -> Result { + let [l, a, b] = xyz_d65_to_oklab_v1(xyz); + let release = AppearanceViewReleaseIdV1::Oklab(OKLAB_VIEW_RELEASE_V1); + Ok(OklabViewV1 { + release: OKLAB_VIEW_RELEASE_V1, + l: FiniteCoordinate::new(l) + .map_err(|reason| view_numeric_error(release, AppearanceViewFieldV1::OklabL, reason))?, + a: FiniteCoordinate::new(a) + .map_err(|reason| view_numeric_error(release, AppearanceViewFieldV1::OklabA, reason))?, + b: FiniteCoordinate::new(b) + .map_err(|reason| view_numeric_error(release, AppearanceViewFieldV1::OklabB, reason))?, + }) +} + +fn derive_cam16_view_v1( + occurrence: LcsOccurrence, +) -> Result { + let context = occurrence.context(); + let surround = match context.surround_profile() { + SurroundProfileId::AverageV1 => Cam16SurroundV1::Average, + SurroundProfileId::DimV1 => Cam16SurroundV1::Dim, + SurroundProfileId::DarkV1 => Cam16SurroundV1::Dark, + }; + let vc = match context.schema_release() { + AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1 => { + ViewingConditions::from_semantic_inputs_v1( + context.adapting_luminance_cd_m2(), + context.background_luminance_ratio(), + surround, + ) + } + }; + let coordinates = forward_correlates_v1(occurrence.sample().xyz(), &vc); + let release = AppearanceViewReleaseIdV1::Cam16(CAM16_VIEW_RELEASE_V1); + let admit = |value, field| { + FiniteNonNegative::new(value).map_err(|reason| view_numeric_error(release, field, reason)) + }; + let j = admit(coordinates.j, AppearanceViewFieldV1::Cam16J)?; + let q = admit(coordinates.q, AppearanceViewFieldV1::Cam16Q)?; + let c = admit(coordinates.c, AppearanceViewFieldV1::Cam16C)?; + let m = admit(coordinates.m, AppearanceViewFieldV1::Cam16M)?; + let s = admit(coordinates.s, AppearanceViewFieldV1::Cam16S)?; + let hue = if m.get() == 0.0 { + HueState::UndefinedExact + } else { + HueState::Defined(HueAngle::new(coordinates.h).map_err(|reason| { + view_numeric_error(release, AppearanceViewFieldV1::Cam16Hue, reason) + })?) + }; + Ok(Cam16ViewV1 { + release: CAM16_VIEW_RELEASE_V1, + j, + q, + c, + m, + s, + hue, + }) +} diff --git a/crates/labcolors-core/src/lcs_occurrence_tests.rs b/crates/labcolors-core/src/lcs_occurrence_tests.rs new file mode 100644 index 00000000..7f9659d9 --- /dev/null +++ b/crates/labcolors-core/src/lcs_occurrence_tests.rs @@ -0,0 +1,667 @@ +use proptest::prelude::*; + +use crate::Srgb8; +use crate::lcs_occurrence::{ + ADMITTED_SRGB8_TRISTIMULUS_BINDING_V1, AdaptingLuminanceCdM2, AppearanceContextFieldV1, + AppearanceContextId, AppearanceContextSchemaReleaseId, AppearanceState, + AppearanceStateDerivationErrorV1, AppearanceViewFieldV1, AppearanceViewReleaseIdV1, + BackgroundLuminanceRatio, CAM16_VIEW_RELEASE_V1, ColorSignal, ColorimetricFrameId, + ColorimetricFrameReleaseId, ColorimetricTransformReleaseId, HueAngle, HueState, + IEC_SRGB_D65_XYZ_FRAME_V1, LcsOccurrence, MUTATION_SENTINEL_XYZ_FRAME_V1, + ModeledTristimulusDerivationV1, NumericDomainError, OKLAB_VIEW_RELEASE_V1, ObserverProfileId, + OccurrenceFormationError, ReferenceWhiteId, SurroundProfileId, TristimulusComponentV1, + TristimulusDomainErrorV1, TristimulusSample, TristimulusScale, derive_modeled_tristimulus_v1, +}; +use crate::spaces::cam16::{ForwardCacheGuard, forward, forward_correlates_v1}; +use crate::spaces::oklab::{srgb_linear_to_oklab, xyz_d65_to_oklab_v1}; +use crate::spaces::srgb::{D65_WHITE, srgb_linear_from_srgb8, srgb_to_xyz}; +use crate::spaces::vc::ViewingConditions; + +fn context(frame: ColorimetricFrameId, la: f64) -> AppearanceContextId { + context_with_surround(frame, la, SurroundProfileId::AverageV1) +} + +fn context_with_surround( + frame: ColorimetricFrameId, + la: f64, + surround: SurroundProfileId, +) -> AppearanceContextId { + AppearanceContextId::from_inputs( + AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1, + frame, + AdaptingLuminanceCdM2::try_new(la).unwrap(), + BackgroundLuminanceRatio::try_new(0.2).unwrap(), + surround, + ) +} + +fn occurrence_from_srgb8(bytes: [u8; 3], context: AppearanceContextId) -> LcsOccurrence { + let sample = derive_modeled_tristimulus_v1(ColorSignal::from_srgb8(Srgb8::new(bytes))) + .unwrap() + .sample(); + LcsOccurrence::in_context(sample, context).unwrap() +} + +#[test] +fn xyz_and_context_numeric_admission_is_fail_closed() { + let relative = IEC_SRGB_D65_XYZ_FRAME_V1; + for xyz in [ + [f64::NAN, 0.0, 0.0], + [0.0, f64::INFINITY, 0.0], + [0.0, 0.0, -f64::MIN_POSITIVE], + ] { + assert!(TristimulusSample::try_from_xyz_for_test(xyz, relative).is_err()); + } + + let zero_la = AdaptingLuminanceCdM2::try_new(0.0).unwrap_err(); + assert_eq!( + zero_la.field(), + AppearanceContextFieldV1::AdaptingLuminanceCdM2 + ); + assert_eq!(zero_la.reason(), NumericDomainError::NotPositive); + + let zero_background = BackgroundLuminanceRatio::try_new(0.0).unwrap_err(); + assert_eq!( + zero_background.field(), + AppearanceContextFieldV1::BackgroundLuminanceRatio, + ); + assert_eq!(zero_background.reason(), NumericDomainError::NotPositive); + + let high_background = BackgroundLuminanceRatio::try_new(1.01).unwrap_err(); + assert_eq!( + high_background.field(), + AppearanceContextFieldV1::BackgroundLuminanceRatio, + ); + assert_eq!(high_background.reason(), NumericDomainError::AboveOne); + + for invalid in [f64::NAN, f64::INFINITY, -f64::MIN_POSITIVE, -0.0] { + assert_eq!( + AdaptingLuminanceCdM2::try_new(invalid).unwrap_err().field(), + AppearanceContextFieldV1::AdaptingLuminanceCdM2, + ); + assert_eq!( + BackgroundLuminanceRatio::try_new(invalid) + .unwrap_err() + .field(), + AppearanceContextFieldV1::BackgroundLuminanceRatio, + ); + } +} + +#[test] +fn hue_algebra_cannot_encode_exact_absence_as_zero_degrees() { + assert_ne!( + HueState::UndefinedExact, + HueState::Defined(HueAngle::new(0.0).unwrap()) + ); +} + +#[test] +fn frame_mismatch_cannot_form_an_occurrence() { + let relative = IEC_SRGB_D65_XYZ_FRAME_V1; + let mismatching = MUTATION_SENTINEL_XYZ_FRAME_V1; + let sample = + derive_modeled_tristimulus_v1(ColorSignal::from_srgb8(Srgb8::new([0x44, 0x88, 0xCC]))) + .unwrap() + .sample(); + assert_eq!(sample.frame(), relative); + assert_eq!( + LcsOccurrence::in_context(sample, context(mismatching, 64.0)), + Err(OccurrenceFormationError::FrameMismatch { + stimulus: relative, + context: mismatching, + }) + ); +} + +#[test] +fn occurrence_identity_contains_only_sample_and_context() { + let relative = IEC_SRGB_D65_XYZ_FRAME_V1; + let sample = TristimulusSample::try_from_xyz_for_test([0.1, 0.2, 0.3], relative).unwrap(); + let occurrence = LcsOccurrence::in_context(sample, context(relative, 64.0)).unwrap(); + assert_eq!(occurrence.sample(), sample); + assert_eq!(occurrence.context(), context(relative, 64.0)); +} + +#[test] +fn context_identity_changes_with_semantic_input_bits() { + let relative = IEC_SRGB_D65_XYZ_FRAME_V1; + assert_ne!(context(relative, 64.0), context(relative, 32.0)); + + let changed_background = AppearanceContextId::from_inputs( + AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1, + relative, + AdaptingLuminanceCdM2::try_new(64.0).unwrap(), + BackgroundLuminanceRatio::try_new(0.3).unwrap(), + SurroundProfileId::AverageV1, + ); + let changed_surround = AppearanceContextId::from_inputs( + AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1, + relative, + AdaptingLuminanceCdM2::try_new(64.0).unwrap(), + BackgroundLuminanceRatio::try_new(0.2).unwrap(), + SurroundProfileId::DimV1, + ); + assert_ne!(context(relative, 64.0), changed_background); + assert_ne!(context(relative, 64.0), changed_surround); +} + +#[test] +fn hue_numeric_constructor_rejects_nonfinite_and_out_of_domain() { + for invalid in [f64::NAN, f64::INFINITY, -0.01, 360.0] { + assert!(HueAngle::new(invalid).is_err()); + } + assert_eq!(HueAngle::new(-0.0).unwrap(), HueAngle::new(0.0).unwrap()); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + #[test] + fn admitted_srgb8_lowering_preserves_existing_decode_and_matrix_bits( + bytes in any::<[u8; 3]>(), + ) { + let signal = ColorSignal::from_srgb8(Srgb8::new(bytes)); + let derived = derive_modeled_tristimulus_v1(signal).unwrap(); + let expected = srgb_to_xyz(srgb_linear_from_srgb8(Srgb8::new(bytes))); + + prop_assert_eq!( + derived.sample().xyz().map(f64::to_bits), + expected.map(f64::to_bits), + ); + prop_assert_eq!(derived.sample().frame(), IEC_SRGB_D65_XYZ_FRAME_V1); + } + + #[test] + fn direct_xyz_oklab_projection_tracks_the_existing_srgb_kernel_without_routing_through_it( + bytes in any::<[u8; 3]>(), + ) { + let linear = srgb_linear_from_srgb8(Srgb8::new(bytes)); + let legacy_srgb_projection = srgb_linear_to_oklab(linear); + let direct_xyz_projection = xyz_d65_to_oklab_v1(srgb_to_xyz(linear)); + + for component in 0..3 { + prop_assert!( + (direct_xyz_projection[component] - legacy_srgb_projection[component]).abs() + <= 2.0e-8, + "component {component}: direct={} existing={}", + direct_xyz_projection[component], + legacy_srgb_projection[component], + ); + } + } +} + +#[test] +fn f0_lowering_signature_accepts_only_one_profiled_signal() { + let lower: fn(ColorSignal) -> Result = + derive_modeled_tristimulus_v1; + assert!(lower(ColorSignal::from_srgb8(Srgb8::new([0; 3]))).is_ok()); +} + +#[test] +fn fixed_corpus_including_eotf_boundary_matches_existing_kernel_bits() { + for bytes in [ + [0x00, 0x00, 0x00], + [0xFF, 0xFF, 0xFF], + [0xFF, 0x00, 0x00], + [0x00, 0xFF, 0x00], + [0x00, 0x00, 0xFF], + [0x0A, 0x0B, 0x80], + [0x44, 0x88, 0xCC], + ] { + let actual = derive_modeled_tristimulus_v1(ColorSignal::from_srgb8(Srgb8::new(bytes))) + .unwrap() + .sample() + .xyz(); + let expected = srgb_to_xyz(srgb_linear_from_srgb8(Srgb8::new(bytes))); + assert_eq!(actual.map(f64::to_bits), expected.map(f64::to_bits)); + } +} + +#[test] +fn transform_release_v1_pins_a_pre_f0_binary64_vector() { + // Recorded once from the pre-F0 decode-table + matrix operation order. This + // is a release anti-drift vector, not a claim of measured or bounded + // colorimetric evidence. + let xyz = + derive_modeled_tristimulus_v1(ColorSignal::from_srgb8(Srgb8::new([0x0A, 0x0B, 0x80]))) + .unwrap() + .sample() + .xyz(); + assert_eq!( + xyz.map(f64::to_bits), + [ + 0x3FA5_334E_5BB6_D38E, + 0x3F93_11B4_45B1_935D, + 0x3FCA_5268_973F_D7F0, + ], + ); +} + +#[test] +fn every_srgb8_channel_code_has_finite_nonnegative_basis_contribution() { + // The registered EOTF acts independently on each byte and the XYZ matrix is + // a sum of three non-negative basis contributions. Exhausting 3 × 256 basis + // inputs therefore covers the finite/non-negative invariant for every mixed + // triplet without adding a 256³ debug-test loop; the property test above + // separately exercises mixed-sum routing and exact operation order. + for byte in u8::MIN..=u8::MAX { + for channel in 0..3 { + let mut bytes = [0_u8; 3]; + bytes[channel] = byte; + let xyz = derive_modeled_tristimulus_v1(ColorSignal::from_srgb8(Srgb8::new(bytes))) + .unwrap() + .sample() + .xyz(); + assert!( + xyz.into_iter() + .all(|component| component.is_finite() && component >= 0.0) + ); + } + } +} + +#[test] +fn admitted_binding_is_one_closed_profile_transform_frame_tuple() { + let binding = ADMITTED_SRGB8_TRISTIMULUS_BINDING_V1; + assert_eq!( + binding.signal_output_profile(), + crate::lcs_occurrence::OutputProfileId::Iec61966Srgb8D65V1, + ); + assert_eq!( + binding.transform_release(), + ColorimetricTransformReleaseId::Iec61966Srgb8ToCie1931TwoDegreeXyzD65RelativeY1V1, + ); + assert_eq!(binding.result_frame(), IEC_SRGB_D65_XYZ_FRAME_V1); + assert_eq!( + binding.result_frame().observer(), + ObserverProfileId::Cie1931TwoDegreeV1, + ); + assert_eq!( + binding.result_frame().reference_white(), + ReferenceWhiteId::Iec61966D65ChromaticityV1, + ); + assert_eq!(binding.result_frame().scale(), TristimulusScale::RelativeY1,); + assert_eq!( + binding.result_frame().release(), + ColorimetricFrameReleaseId::XyzV1, + ); +} + +#[test] +fn black_is_positive_zero_and_white_tracks_the_existing_matrix() { + let black = derive_modeled_tristimulus_v1(ColorSignal::from_srgb8(Srgb8::new([0; 3]))) + .unwrap() + .sample() + .xyz(); + assert_eq!(black.map(f64::to_bits), [0_u64; 3]); + + let white = derive_modeled_tristimulus_v1(ColorSignal::from_srgb8(Srgb8::new([255; 3]))) + .unwrap() + .sample() + .xyz(); + let matrix_white = srgb_to_xyz([1.0; 3]); + assert_eq!(white.map(f64::to_bits), matrix_white.map(f64::to_bits)); + for (actual, reference) in white.into_iter().zip(D65_WHITE) { + assert!((actual - reference).abs() <= f64::EPSILON); + } +} + +#[test] +fn modeled_derivation_is_content_bound_replayable_and_allocation_free() { + let signal = ColorSignal::from_srgb8(Srgb8::new([0x0A, 0x0B, 0x80])); + let (derived, lowering_allocations) = + crate::test_support::measured_allocations(|| derive_modeled_tristimulus_v1(signal)); + let derived = derived.unwrap(); + let (replayed, replay_allocations) = + crate::test_support::measured_allocations(|| derived.replay()); + + assert_eq!(lowering_allocations, 0); + assert_eq!(replay_allocations, 0); + assert_eq!(replayed.unwrap(), derived.sample()); + assert_eq!(derived.provenance().source_signal(), signal); + assert_eq!( + derived.provenance().binding(), + ADMITTED_SRGB8_TRISTIMULUS_BINDING_V1, + ); + + let changed_signal = ColorSignal::from_srgb8(Srgb8::new([0x0B, 0x0B, 0x80])); + let changed = derive_modeled_tristimulus_v1(changed_signal).unwrap(); + assert_ne!(derived.provenance(), changed.provenance()); + assert_ne!(derived.sample(), changed.sample()); + assert_eq!(derived.provenance().source_signal(), signal); +} + +#[test] +fn raw_xyz_admission_is_test_only_component_qualified_and_fail_closed() { + let frame = IEC_SRGB_D65_XYZ_FRAME_V1; + for (xyz, component, reason) in [ + ( + [f64::NAN, 0.0, 0.0], + TristimulusComponentV1::X, + NumericDomainError::NonFinite, + ), + ( + [0.0, f64::INFINITY, 0.0], + TristimulusComponentV1::Y, + NumericDomainError::NonFinite, + ), + ( + [0.0, 0.0, -f64::MIN_POSITIVE], + TristimulusComponentV1::Z, + NumericDomainError::Negative, + ), + ] { + let error = TristimulusSample::try_from_xyz_for_test(xyz, frame).unwrap_err(); + assert_eq!(error.component(), component); + assert_eq!(error.reason(), reason); + } + + let admitted = TristimulusSample::try_from_xyz_for_test([-0.0, 0.5, 1.25], frame).unwrap(); + assert_eq!(admitted.xyz()[0].to_bits(), 0.0_f64.to_bits()); + assert_eq!(admitted.xyz()[2], 1.25); +} + +#[test] +fn appearance_context_identity_exposes_only_semantic_inputs() { + let context = context(IEC_SRGB_D65_XYZ_FRAME_V1, 64.0); + assert_eq!( + context.schema_release(), + AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1, + ); + assert_eq!(context.frame(), IEC_SRGB_D65_XYZ_FRAME_V1); + assert_eq!(context.adapting_luminance_cd_m2(), 64.0); + assert_eq!(context.background_luminance_ratio(), 0.2); + assert_eq!(context.surround_profile(), SurroundProfileId::AverageV1); +} + +#[test] +fn appearance_state_is_one_way_views_of_the_same_occurrence_with_separate_releases() { + let occurrence = + occurrence_from_srgb8([0x00, 0x00, 0xFF], context(IEC_SRGB_D65_XYZ_FRAME_V1, 64.0)); + let state = AppearanceState::derive_v1(occurrence).unwrap(); + + assert_eq!(state.occurrence(), occurrence); + assert_eq!(state.oklab().release(), OKLAB_VIEW_RELEASE_V1); + assert_eq!(state.cam16().release(), CAM16_VIEW_RELEASE_V1); + + let direct = xyz_d65_to_oklab_v1(occurrence.sample().xyz()); + assert_eq!( + [state.oklab().l(), state.oklab().a(), state.oklab().b()].map(f64::to_bits), + direct.map(f64::to_bits), + ); + + let expected_cam = forward_correlates_v1(occurrence.sample().xyz(), &ViewingConditions::srgb()); + let cam = state.cam16(); + assert_eq!(cam.j().to_bits(), expected_cam.j.to_bits()); + assert_eq!(cam.q().to_bits(), expected_cam.q.to_bits()); + assert_eq!(cam.c().to_bits(), expected_cam.c.to_bits()); + assert_eq!(cam.m().to_bits(), expected_cam.m.to_bits()); + assert_eq!(cam.s().to_bits(), expected_cam.s.to_bits()); + let HueState::Defined(cam_hue) = cam.hue() else { + panic!("chromatic blue must have a CAM16 hue"); + }; + assert_eq!(cam_hue.degrees().to_bits(), expected_cam.h.to_bits()); + + let oklab_hue = state.oklab().b().atan2(state.oklab().a()).to_degrees(); + let oklab_hue = if oklab_hue < 0.0 { + oklab_hue + 360.0 + } else { + oklab_hue + }; + assert!( + (cam_hue.degrees() - oklab_hue).abs() > 10.0, + "CAM16 hue must not be copied from Oklab: CAM16={} Oklab={oklab_hue}", + cam_hue.degrees(), + ); +} + +#[test] +fn context_changes_only_the_contextual_cam16_view() { + let sample = + derive_modeled_tristimulus_v1(ColorSignal::from_srgb8(Srgb8::new([0x44, 0x88, 0xCC]))) + .unwrap() + .sample(); + let average = AppearanceState::derive_v1( + LcsOccurrence::in_context( + sample, + context_with_surround( + IEC_SRGB_D65_XYZ_FRAME_V1, + 64.0, + SurroundProfileId::AverageV1, + ), + ) + .unwrap(), + ) + .unwrap(); + let dim = AppearanceState::derive_v1( + LcsOccurrence::in_context( + sample, + context_with_surround(IEC_SRGB_D65_XYZ_FRAME_V1, 64.0, SurroundProfileId::DimV1), + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(average.oklab(), dim.oklab()); + assert_ne!(average.cam16().j().to_bits(), dim.cam16().j().to_bits()); + assert_ne!(average.cam16().m().to_bits(), dim.cam16().m().to_bits()); + assert_ne!(average.occurrence(), dim.occurrence()); +} + +#[test] +fn every_registered_surround_maps_to_its_exact_cam16_kernel_tuple() { + for (surround, vc) in [ + (SurroundProfileId::AverageV1, ViewingConditions::srgb()), + (SurroundProfileId::DimV1, ViewingConditions::dim_surround()), + ( + SurroundProfileId::DarkV1, + ViewingConditions::dark_surround(), + ), + ] { + let occurrence = occurrence_from_srgb8( + [0x44, 0x88, 0xCC], + context_with_surround(IEC_SRGB_D65_XYZ_FRAME_V1, 64.0, surround), + ); + let actual = AppearanceState::derive_v1(occurrence).unwrap().cam16(); + let expected = forward_correlates_v1(occurrence.sample().xyz(), &vc); + assert_eq!( + [actual.j(), actual.q(), actual.c(), actual.m(), actual.s(),].map(f64::to_bits), + [expected.j, expected.q, expected.c, expected.m, expected.s,].map(f64::to_bits), + "surround {surround:?} must not mix registered tuple fields", + ); + let HueState::Defined(actual_hue) = actual.hue() else { + panic!("chromatic fixture must have hue under {surround:?}"); + }; + assert_eq!(actual_hue.degrees().to_bits(), expected.h.to_bits()); + } +} + +#[test] +fn exact_zero_coordinate_has_no_invented_hue() { + let state = AppearanceState::derive_v1(occurrence_from_srgb8( + [0; 3], + context(IEC_SRGB_D65_XYZ_FRAME_V1, 64.0), + )) + .unwrap(); + + assert_eq!( + [state.oklab().l(), state.oklab().a(), state.oklab().b()].map(f64::to_bits), + [0; 3], + ); + assert_eq!(state.cam16().j().to_bits(), 0); + assert_eq!(state.cam16().q().to_bits(), 0); + assert_eq!(state.cam16().c().to_bits(), 0); + assert_eq!(state.cam16().m().to_bits(), 0); + assert_eq!(state.cam16().s().to_bits(), 0); + assert_eq!(state.cam16().hue(), HueState::UndefinedExact); +} + +#[test] +fn state_derivation_rejects_an_unregistered_frame_before_any_view_math() { + let frame = MUTATION_SENTINEL_XYZ_FRAME_V1; + let sample = TristimulusSample::try_from_xyz_for_test([0.1, 0.2, 0.3], frame).unwrap(); + let occurrence = LcsOccurrence::in_context(sample, context(frame, 64.0)).unwrap(); + + assert_eq!( + AppearanceState::derive_v1(occurrence).unwrap_err(), + AppearanceStateDerivationErrorV1::UnsupportedFrame { frame }, + ); +} + +#[test] +fn state_derivation_rejects_nonfinite_derived_coordinates_with_release_and_field() { + let frame = IEC_SRGB_D65_XYZ_FRAME_V1; + let sample = TristimulusSample::try_from_xyz_for_test([f64::MAX; 3], frame).unwrap(); + let occurrence = LcsOccurrence::in_context(sample, context(frame, 64.0)).unwrap(); + + assert_eq!( + AppearanceState::derive_v1(occurrence).unwrap_err(), + AppearanceStateDerivationErrorV1::NumericDomain { + release: AppearanceViewReleaseIdV1::Oklab(OKLAB_VIEW_RELEASE_V1), + field: AppearanceViewFieldV1::OklabL, + reason: NumericDomainError::NonFinite, + }, + ); +} + +#[test] +fn direct_oklab_release_pins_an_external_xyz_projection_vector() { + // Fixed vector from the CSS Color 4 direct XYZ(D65) -> Oklab matrices for + // the already-pinned `[0A, 0B, 80]` F0 tristimulus. Tolerance covers only + // cross-libm cbrt ULPs; it is far below the direct-vs-legacy matrix delta. + let state = AppearanceState::derive_v1(occurrence_from_srgb8( + [0x0A, 0x0B, 0x80], + context(IEC_SRGB_D65_XYZ_FRAME_V1, 64.0), + )) + .unwrap(); + let actual = [state.oklab().l(), state.oklab().a(), state.oklab().b()]; + let expected = [ + 0.284_226_036_666_170_47, + -0.009_591_562_466_562_426, + -0.178_614_436_252_897_94, + ]; + for component in 0..3 { + assert!( + (actual[component] - expected[component]).abs() <= 1.0e-14, + "Oklab component {component} drifted: actual={} expected={}", + actual[component], + expected[component], + ); + } +} + +#[test] +fn cam16_release_pins_a_full_external_correlate_vector() { + // colour-science CIECAM16, D65, L_A=64 cd/m², Y_b/Y_w=0.2, average + // surround. Unlike the older J/M/h table this pins the newly exposed + // Q/C/s correlates as well. The tolerances cover only the documented CSS + // XYZ constant delta from colour-science's matrix derivation. + let state = AppearanceState::derive_v1(occurrence_from_srgb8( + [0x00, 0x00, 0xFF], + context(IEC_SRGB_D65_XYZ_FRAME_V1, 64.0), + )) + .unwrap(); + let cam = state.cam16(); + let HueState::Defined(hue) = cam.hue() else { + panic!("reference blue must retain a CAM16 hue"); + }; + + for (name, actual, expected, tolerance) in [ + ("J", cam.j(), 25.271_208_691_856_113, 0.01), + ("Q", cam.q(), 109.108_232_192_767_33, 0.1), + ("C", cam.c(), 86.580_098_936_732_16, 0.1), + ("M", cam.m(), 78.737_310_637_269_06, 0.05), + ("s", cam.s(), 84.949_637_273_879, 0.1), + ("h", hue.degrees(), 282.871_080_928_130_14, 0.15), + ] { + assert!( + (actual - expected).abs() < tolerance, + "CAM16 {name} drifted: actual={actual} expected={expected}", + ); + } +} + +#[test] +fn full_appearance_state_derivation_is_allocation_free() { + let occurrence = + occurrence_from_srgb8([0x44, 0x88, 0xCC], context(IEC_SRGB_D65_XYZ_FRAME_V1, 64.0)); + let (derived, allocations) = + crate::test_support::measured_allocations(|| AppearanceState::derive_v1(occurrence)); + + assert_eq!(allocations, 0); + assert!(derived.is_ok()); +} + +#[test] +fn appearance_state_bypasses_active_xyz_only_cache_for_each_context_without_allocating() { + let frame = IEC_SRGB_D65_XYZ_FRAME_V1; + let sample = + derive_modeled_tristimulus_v1(ColorSignal::from_srgb8(Srgb8::new([0x44, 0x88, 0xCC]))) + .unwrap() + .sample(); + let xyz = sample.xyz(); + + // Freeze the per-context cache-free answers before activating the legacy + // XYZ-only cache. These are independent of its ambient guard state. + let expected_dim = forward_correlates_v1(xyz, &ViewingConditions::dim_surround()); + let expected_dark = forward_correlates_v1(xyz, &ViewingConditions::dark_surround()); + + let dim_occurrence = LcsOccurrence::in_context( + sample, + context_with_surround(frame, 64.0, SurroundProfileId::DimV1), + ) + .unwrap(); + let dark_occurrence = LcsOccurrence::in_context( + sample, + context_with_surround(frame, 64.0, SurroundProfileId::DarkV1), + ) + .unwrap(); + + let _guard = ForwardCacheGuard::activate(); + let cached_average = forward(xyz, &ViewingConditions::srgb()); + assert_ne!(cached_average.0.to_bits(), expected_dim.j.to_bits()); + assert_ne!(cached_average.0.to_bits(), expected_dark.j.to_bits()); + + let (dim, dim_allocations) = + crate::test_support::measured_allocations(|| AppearanceState::derive_v1(dim_occurrence)); + let (dark, dark_allocations) = + crate::test_support::measured_allocations(|| AppearanceState::derive_v1(dark_occurrence)); + let dim = dim.unwrap().cam16(); + let dark = dark.unwrap().cam16(); + + assert_eq!(dim_allocations, 0); + assert_eq!(dark_allocations, 0); + assert_eq!( + [dim.j(), dim.q(), dim.c(), dim.m(), dim.s()].map(f64::to_bits), + [ + expected_dim.j, + expected_dim.q, + expected_dim.c, + expected_dim.m, + expected_dim.s, + ] + .map(f64::to_bits), + ); + assert_eq!( + [dark.j(), dark.q(), dark.c(), dark.m(), dark.s()].map(f64::to_bits), + [ + expected_dark.j, + expected_dark.q, + expected_dark.c, + expected_dark.m, + expected_dark.s, + ] + .map(f64::to_bits), + ); + + let HueState::Defined(dim_hue) = dim.hue() else { + panic!("chromatic dim fixture must have CAM16 hue"); + }; + let HueState::Defined(dark_hue) = dark.hue() else { + panic!("chromatic dark fixture must have CAM16 hue"); + }; + assert_eq!(dim_hue.degrees().to_bits(), expected_dim.h.to_bits()); + assert_eq!(dark_hue.degrees().to_bits(), expected_dark.h.to_bits()); +} diff --git a/crates/labcolors-core/src/lib.rs b/crates/labcolors-core/src/lib.rs index bd470c2f..14c9df24 100644 --- a/crates/labcolors-core/src/lib.rs +++ b/crates/labcolors-core/src/lib.rs @@ -22,10 +22,20 @@ pub mod glow; pub mod hash; pub mod ladder; pub mod lcs; +#[expect( + dead_code, + reason = "private F0 colour-identity foundation precedes the terminal public hard cut" +)] +pub(crate) mod lcs_occurrence; pub(crate) mod lpc; pub mod material; pub mod neutral; pub mod numerical_plan; +#[expect( + dead_code, + reason = "private F0 output-projection release firewall precedes the atomic public hard cut" +)] +pub(crate) mod output_projection; pub(crate) mod pair; #[cfg_attr( not(test), @@ -35,6 +45,22 @@ pub(crate) mod pair; ) )] pub(crate) mod point_support; +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "private Program compiler/lowering precedes its direct sole-Session bridge" + ) +)] +pub(crate) mod program_session; +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "private F0 release registry precedes the atomic public hard cut" + ) +)] +pub(crate) mod release_registry; pub mod scale; pub mod semantic; pub mod solve; @@ -57,6 +83,21 @@ mod agnostic_gates; #[cfg(test)] mod appearance_graph_tests; +#[cfg(test)] +mod lcs_occurrence_tests; + +#[cfg(test)] +mod output_projection_tests; + +#[cfg(test)] +mod program_session_tests; + +#[cfg(test)] +mod release_registry_tests; + +#[cfg(test)] +mod generic_boundary_tests; + #[cfg_attr( not(test), expect( @@ -329,7 +370,7 @@ pub struct NoPublicPairRecipeApi; /// ``` /// /// ```compile_fail -/// use labcolors_core::session::PointSupportSessionV1; +/// use labcolors_core::session::Session; /// ``` #[cfg(doctest)] pub struct NoPrematurePointSupportApi; diff --git a/crates/labcolors-core/src/numerical_plan.rs b/crates/labcolors-core/src/numerical_plan.rs index bd05d4f5..ab674f6d 100644 --- a/crates/labcolors-core/src/numerical_plan.rs +++ b/crates/labcolors-core/src/numerical_plan.rs @@ -271,6 +271,8 @@ pub fn compile_numerical_plan_v1<'a>( #[cfg(test)] mod tests { + use std::fmt::Write as _; + use super::*; const SITE: NumericalSiteIdV1 = NumericalSiteIdV1::GlowTargetOrMaximumV1; @@ -386,11 +388,11 @@ mod tests { (b"z".as_slice(), SITE, compatibility()), ]) .unwrap(); - let hex: String = plan - .canonical_checksum_preimage() - .iter() - .map(|b| format!("{b:02x}")) - .collect(); + let preimage = plan.canonical_checksum_preimage(); + let mut hex = String::with_capacity(preimage.len() * 2); + for byte in preimage { + write!(&mut hex, "{byte:02x}").expect("writing to String cannot fail"); + } let frozen = "1b0000006c6162636f6c6f72732e6e756d65726963616c2d706c616e2e763101000000020000004f000000210000006c6162636f6c6f72732e6e756d65726963616c2d696e766f636174696f6e2e763101000000010000006119000000676c6f772d7461726765742d6f722d6d6178696d756d2d76310000000019000000676c6f772d7461726765742d6f722d6d6178696d756d2d76310b000000737461626c652d6f6e6c79000000004f000000210000006c6162636f6c6f72732e6e756d65726963616c2d696e766f636174696f6e2e763101000000010000007a19000000676c6f772d7461726765742d6f722d6d6178696d756d2d76310000000019000000676c6f772d7461726765742d6f722d6d6178696d756d2d7631160000006578706c696369742d636f6d7061746962696c69747926000000676c6f772d63616d31362d7563732d6a7072696d652d7461726765742d6f722d6d61782d7631"; assert_eq!(hex, frozen, "canonical plan encoding v1 заморожен"); assert_eq!(plan.checksum.hex(), "49e5b6b7"); diff --git a/crates/labcolors-core/src/observation.rs b/crates/labcolors-core/src/observation.rs index a67d813a..6f2b9c86 100644 --- a/crates/labcolors-core/src/observation.rs +++ b/crates/labcolors-core/src/observation.rs @@ -5,10 +5,21 @@ //! only code allowed to bind an admitted observation to evaluator evidence. use core::ops::Range; +use std::rc::Rc; use crate::Srgb8; use crate::appearance::SurfaceInputPortId; +/// Stable compile-time identity of one atomic observation boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) struct ObservationGroupId(u32); + +impl ObservationGroupId { + pub(crate) const fn new(raw: u32) -> Self { + Self(raw) + } +} + /// Runtime instance/epoch of one atomic observation stream. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub(crate) struct ObservationStreamId(u32); @@ -60,14 +71,6 @@ impl SurfaceInputBinding { pub(crate) const fn new(port: SurfaceInputPortId, value: Srgb8) -> Self { Self { port, value } } - - pub(crate) const fn port(self) -> SurfaceInputPortId { - self.port - } - - pub(crate) const fn value(self) -> Srgb8 { - self.value - } } /// Raw tuple: every binding was observed simultaneously. @@ -98,22 +101,16 @@ pub(crate) struct ObservationUpdateInput { pub(crate) payload: ObservationPayloadInput, } -/// One unique physical tuple as canonical keyed bindings. Port identity travels -/// with every value, so an observation cannot be reinterpreted through a -/// different positional schema. +/// One unique physical tuple inside the shared canonical backing. +/// +/// Values are stored once in schema order. The schema remains attached to the +/// backing itself instead of being repeated beside every value. #[derive(Debug, PartialEq, Eq)] -#[cfg_attr(test, derive(Clone))] -pub(crate) struct PhysicalScenario { - bindings: Vec, +struct PhysicalScenario { + values: Range, provenance: Range, } -impl PhysicalScenario { - pub(crate) fn bindings(&self) -> &[SurfaceInputBinding] { - &self.bindings - } -} - /// First canonical ordinal where an observation's intrinsic keyed schema and a /// compiled consumer schema differ. `None` represents an exhausted side. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -140,56 +137,59 @@ impl ObservationSchemaMismatchV1 { } } -/// Canonical nonempty set. Provenance is one flat allocation shared by ranges, -/// not one allocation per physical case. +/// Canonical nonempty correlated set. Values and provenance each use one flat +/// allocation; a physical case owns only ranges into those arrays. #[derive(Debug, PartialEq, Eq)] -#[cfg_attr(test, derive(Clone))] -pub(crate) struct ObservedScenarioSet { - cases: Vec, - provenance: Vec, +struct ObservedScenarioSet { + cases: Box<[PhysicalScenario]>, + values: Box<[Srgb8]>, + provenance: Box<[ScenarioId]>, } impl ObservedScenarioSet { - pub(crate) fn cases(&self) -> &[PhysicalScenario] { - &self.cases + fn values(&self, case_index: usize) -> Option<&[Srgb8]> { + let values = &self.cases.get(case_index)?.values; + self.values.get(values.start..values.end) } - pub(crate) fn provenance(&self, case_index: usize) -> Option<&[ScenarioId]> { + fn provenance(&self, case_index: usize) -> Option<&[ScenarioId]> { let provenance = &self.cases.get(case_index)?.provenance; - let range = provenance.start..provenance.end; - self.provenance.get(range) + self.provenance.get(provenance.start..provenance.end) } +} - fn validate_surface_schema( - &self, - expected: &[SurfaceInputPortId], - ) -> Result<(), ObservationSchemaMismatchV1> { - for (case_index, case) in self.cases.iter().enumerate() { - let schema_len = expected.len().max(case.bindings.len()); - for binding_index in 0..schema_len { - let expected_input = expected.get(binding_index).copied(); - let actual_input = case.bindings.get(binding_index).map(|binding| binding.port); - if expected_input != actual_input { - return Err(ObservationSchemaMismatchV1::new( - case_index, - binding_index, - expected_input, - actual_input, - )); - } - } - } - Ok(()) +/// Canonical immutable schema shared by the compiled recheck and every +/// admitted observation backing created for it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CanonicalObservationSchemaV1(Rc<[SurfaceInputPortId]>); + +impl CanonicalObservationSchemaV1 { + pub(crate) fn as_slice(&self) -> &[SurfaceInputPortId] { + &self.0 + } + + fn shares_backing_with(&self, other: &Self) -> bool { + Rc::ptr_eq(&self.0, &other.0) + } + + #[cfg(test)] + pub(crate) fn backing_ptr_for_test(&self) -> *const SurfaceInputPortId { + self.0.as_ptr() } } -/// Sealed observation admitted against the Session-owned compiled schema. #[derive(Debug, PartialEq, Eq)] -#[cfg_attr(test, derive(Clone))] +struct ObservationBackingV1 { + schema: CanonicalObservationSchemaV1, + set: ObservedScenarioSet, +} + +/// Sealed observation admitted against the Session-owned compiled schema. +#[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct RevisionBoundObservationV1 { stream: ObservationStreamId, revision: Revision, - set: ObservedScenarioSet, + backing: Rc, } impl RevisionBoundObservationV1 { @@ -201,30 +201,72 @@ impl RevisionBoundObservationV1 { self.revision } - pub(crate) const fn set(&self) -> &ObservedScenarioSet { - &self.set + pub(crate) fn schema(&self) -> &[SurfaceInputPortId] { + self.backing.schema.as_slice() } pub(crate) fn physical_case_count(&self) -> usize { - self.set.cases().len() + self.backing.set.cases.len() } - pub(crate) fn physical_bindings(&self, case_index: usize) -> Option<&[SurfaceInputBinding]> { - self.set - .cases() - .get(case_index) - .map(PhysicalScenario::bindings) + pub(crate) fn physical_values(&self, case_index: usize) -> Option<&[Srgb8]> { + self.backing.set.values(case_index) } pub(crate) fn provenance(&self, case_index: usize) -> Option<&[ScenarioId]> { - self.set.provenance(case_index) + self.backing.set.provenance(case_index) } pub(crate) fn validate_surface_schema( &self, expected: &[SurfaceInputPortId], ) -> Result<(), ObservationSchemaMismatchV1> { - self.set.validate_surface_schema(expected) + let actual = self.schema(); + let schema_len = expected.len().max(actual.len()); + for binding_index in 0..schema_len { + let expected_input = expected.get(binding_index).copied(); + let actual_input = actual.get(binding_index).copied(); + if expected_input != actual_input { + return Err(ObservationSchemaMismatchV1::new( + 0, + binding_index, + expected_input, + actual_input, + )); + } + } + Ok(()) + } + + pub(crate) fn shares_schema_backing_with( + &self, + expected: &CanonicalObservationSchemaV1, + ) -> bool { + self.backing.schema.shares_backing_with(expected) + } + + pub(crate) fn is_same_binding_as(&self, other: &Self) -> bool { + self.stream == other.stream + && self.revision == other.revision + && Rc::ptr_eq(&self.backing, &other.backing) + } + + fn has_canonical_input( + &self, + schema: &CanonicalObservationSchemaV1, + scenarios: &[ScenarioInput], + ) -> bool { + &self.backing.schema == schema && canonical_input_matches_set(&self.backing.set, scenarios) + } + + #[cfg(test)] + pub(crate) fn backing_ptr_for_test(&self) -> *const () { + Rc::as_ptr(&self.backing).cast() + } + + #[cfg(test)] + pub(crate) fn schema_ptr_for_test(&self) -> *const SurfaceInputPortId { + self.backing.schema.backing_ptr_for_test() } } @@ -365,11 +407,15 @@ pub(crate) enum PreparedObservationUpdateV1<'owner, Owner> { Observed(PreparedObservedV1<'owner, Owner>), } -/// Canonicalize and validate a compiled surface-input schema without any new -/// allocation. The supplied Vec remains the unique schema owner. +/// Canonicalize and validate a compiled surface-input schema. +/// +/// Sorting and duplicate detection reuse the supplied `Vec`. Converting its +/// storage into the immutable `Rc`-backed schema can still allocate through the +/// global allocator; this function does not claim allocator-wide recoverable +/// OOM semantics. pub(crate) fn canonicalize_observation_schema( mut ports: Vec, -) -> Result, ObservationError> { +) -> Result { if ports.is_empty() { return Err(ObservationError::EmptyCompiledSurfaceInputSchema); } @@ -379,16 +425,20 @@ pub(crate) fn canonicalize_observation_schema( input: duplicate[0], }); } - Ok(ports) + Ok(CanonicalObservationSchemaV1(Rc::from( + ports.into_boxed_slice(), + ))) } -/// Prepare without mutation. Cheap stream/lower-revision/payload-kind failures -/// precede scenario canonicalization; exact same-revision observed replay still -/// canonicalizes so equality is content-complete. +/// Prepare without mutation. Stream identity always precedes payload handling. +/// `Scenarios` are fully admitted before revision comparison so malformed +/// correlated input is never masked by an otherwise valid revision error. +/// `Unknown` has no payload structure to admit and takes the cheap revision +/// path directly. pub(crate) fn prepare_observation<'owner, Owner: ObservationOwnerV1>( owner: &'owner mut Owner, stream: ObservationStreamId, - schema: &[SurfaceInputPortId], + schema: &CanonicalObservationSchemaV1, update: ObservationUpdateInput, ) -> Result, ObservationError> { if update.stream != stream { @@ -398,18 +448,17 @@ pub(crate) fn prepare_observation<'owner, Owner: ObservationOwnerV1>( }); } - let current_revision = owner.observation_head().revision(); - if let Some(current) = current_revision { - if update.revision < current { - return Err(ObservationError::RevisionOutOfOrder { - current, - incoming: update.revision, - }); - } - } - match update.payload { ObservationPayloadInput::Unknown(reason) => { + let current_revision = owner.observation_head().revision(); + if let Some(current) = current_revision { + if update.revision < current { + return Err(ObservationError::RevisionOutOfOrder { + current, + incoming: update.revision, + }); + } + } if current_revision == Some(update.revision) { let exact = matches!( owner.observation_head(), @@ -435,6 +484,16 @@ pub(crate) fn prepare_observation<'owner, Owner: ObservationOwnerV1>( })) } ObservationPayloadInput::Scenarios(raw) => { + let scenarios = canonicalize_scenarios_input(schema.as_slice(), raw)?; + let current_revision = owner.observation_head().revision(); + if let Some(current) = current_revision { + if update.revision < current { + return Err(ObservationError::RevisionOutOfOrder { + current, + incoming: update.revision, + }); + } + } if current_revision == Some(update.revision) && !matches!(owner.observation_head(), ObservationHeadViewV1::Observed(_)) { @@ -443,11 +502,10 @@ pub(crate) fn prepare_observation<'owner, Owner: ObservationOwnerV1>( }); } - let set = admit_scenarios(schema, raw)?; if current_revision == Some(update.revision) { let exact = matches!( - owner.observation_head(), - ObservationHeadViewV1::Observed(current) if current.set == set + owner.observation_head(), ObservationHeadViewV1::Observed(current) + if current.has_canonical_input(schema, &scenarios) ); return if exact { Ok(PreparedObservationUpdateV1::Idempotent( @@ -460,22 +518,26 @@ pub(crate) fn prepare_observation<'owner, Owner: ObservationOwnerV1>( }; } + let set = materialize_scenarios(schema.as_slice(), scenarios)?; Ok(PreparedObservationUpdateV1::Observed(PreparedObservedV1 { owner, observation: RevisionBoundObservationV1 { stream, revision: update.revision, - set, + backing: Rc::new(ObservationBackingV1 { + schema: schema.clone(), + set, + }), }, })) } } } -fn admit_scenarios( +fn canonicalize_scenarios_input( schema: &[SurfaceInputPortId], raw: ObservedScenarioSetInput, -) -> Result { +) -> Result, ObservationError> { if raw.scenarios.is_empty() { return Err(ObservationError::EmptyScenarioSet); } @@ -491,11 +553,7 @@ fn admit_scenarios( }); } - let mut tuples = Vec::new(); - tuples - .try_reserve_exact(scenarios.len()) - .map_err(|_| ObservationError::ResourceExhausted)?; - for mut scenario in scenarios { + for scenario in &mut scenarios { scenario .bindings .sort_unstable_by_key(|binding| binding.port); @@ -531,42 +589,120 @@ fn admit_scenarios( input: unexpected.port, }); } + } + + // Reuse the caller-owned outer Vec and every bindings Vec. This complete + // canonical input is enough for lower/same-revision decisions without any + // new allocation; only an applied higher revision materializes backing. + scenarios.sort_unstable_by(|left, right| { + left.bindings + .cmp(&right.bindings) + .then_with(|| left.id.cmp(&right.id)) + }); + Ok(scenarios) +} + +fn canonical_input_matches_set(set: &ObservedScenarioSet, scenarios: &[ScenarioInput]) -> bool { + let mut case_index = 0; + let mut scenario_index = 0; + while scenario_index < scenarios.len() { + let bindings = &scenarios[scenario_index].bindings; + let Some(values) = set.values(case_index) else { + return false; + }; + if bindings.len() != values.len() + || bindings + .iter() + .zip(values) + .any(|(binding, value)| binding.value != *value) + { + return false; + } - // Move the already allocated, sorted keyed tuple directly. Keeping the - // port beside the value makes schema identity part of observation - // equality and removes any later positional reinterpretation seam. - tuples.push((scenario.bindings, scenario.id)); + let first = scenario_index; + scenario_index += 1; + while scenario_index < scenarios.len() + && scenarios[scenario_index].bindings.as_slice() == bindings.as_slice() + { + scenario_index += 1; + } + let Some(provenance) = set.provenance(case_index) else { + return false; + }; + if provenance.len() != scenario_index - first + || scenarios[first..scenario_index] + .iter() + .zip(provenance) + .any(|(scenario, provenance)| scenario.id != *provenance) + { + return false; + } + case_index += 1; } + case_index == set.cases.len() +} - tuples.sort_unstable_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1))); +fn materialize_scenarios( + schema: &[SurfaceInputPortId], + scenarios: Vec, +) -> Result { + debug_assert!(!scenarios.is_empty()); - // Both output allocations are reserved before grouping. Moving tuples and - // pushing into these capacities cannot allocate afterward. + let unique_case_count = 1 + scenarios + .windows(2) + .filter(|window| window[0].bindings.as_slice() != window[1].bindings.as_slice()) + .count(); + let value_count = unique_case_count + .checked_mul(schema.len()) + .ok_or(ObservationError::ResourceExhausted)?; + + // Capacity for every variable-sized Vec used by grouping is fallibly + // reserved before the first push/extend, so grouping cannot grow one of + // those Vecs. The final boxed representation and its later Rc owner still + // follow the global allocator's OOM behavior. let mut cases = Vec::new(); cases - .try_reserve_exact(tuples.len()) + .try_reserve_exact(unique_case_count) + .map_err(|_| ObservationError::ResourceExhausted)?; + let mut values = Vec::new(); + values + .try_reserve_exact(value_count) .map_err(|_| ObservationError::ResourceExhausted)?; let mut provenance = Vec::new(); provenance - .try_reserve_exact(tuples.len()) + .try_reserve_exact(scenarios.len()) .map_err(|_| ObservationError::ResourceExhausted)?; - let mut tuples = tuples.into_iter().peekable(); - while let Some((bindings, first_id)) = tuples.next() { - let start = provenance.len(); + let mut scenarios = scenarios.into_iter().peekable(); + while let Some(ScenarioInput { + id: first_id, + bindings, + }) = scenarios.next() + { + let values_start = values.len(); + values.extend(bindings.iter().map(|binding| binding.value)); + let values_end = values.len(); + let provenance_start = provenance.len(); provenance.push(first_id); - while matches!(tuples.peek(), Some((candidate, _)) if candidate == &bindings) { - let (_, id) = tuples + while matches!(scenarios.peek(), Some(candidate) if candidate.bindings.as_slice() == bindings.as_slice()) + { + let ScenarioInput { id, .. } = scenarios .next() - .unwrap_or_else(|| unreachable!("peek observed the next tuple")); + .unwrap_or_else(|| unreachable!("peek observed the next scenario")); provenance.push(id); } - let end = provenance.len(); + let provenance_end = provenance.len(); cases.push(PhysicalScenario { - bindings, - provenance: start..end, + values: values_start..values_end, + provenance: provenance_start..provenance_end, }); } - Ok(ObservedScenarioSet { cases, provenance }) + debug_assert_eq!(cases.len(), unique_case_count); + debug_assert_eq!(values.len(), value_count); + Ok(ObservedScenarioSet { + cases: cases.into_boxed_slice(), + values: values.into_boxed_slice(), + provenance: provenance.into_boxed_slice(), + }) } diff --git a/crates/labcolors-core/src/observation_tests.rs b/crates/labcolors-core/src/observation_tests.rs index 33b0f9ed..01a4495d 100644 --- a/crates/labcolors-core/src/observation_tests.rs +++ b/crates/labcolors-core/src/observation_tests.rs @@ -5,10 +5,10 @@ use crate::appearance::{ ResolvedOccurrence, SurfaceId, SurfaceInputPortId, SurfaceSpec, }; use crate::observation::{ - ObservationError, ObservationHeadViewV1, ObservationOwnerV1, ObservationPayloadInput, - ObservationSchemaMismatchV1, ObservationStreamId, ObservationUpdateInput, ObservedScenarioSet, - ObservedScenarioSetInput, PreparedObservationUpdateV1, Revision, RevisionBoundObservationV1, - RevisionBoundUnknownV1, ScenarioId, ScenarioInput, SurfaceInputBinding, UnknownReasonId, + CanonicalObservationSchemaV1, ObservationError, ObservationHeadViewV1, ObservationOwnerV1, + ObservationPayloadInput, ObservationStreamId, ObservationUpdateInput, ObservedScenarioSetInput, + PreparedObservationUpdateV1, Revision, RevisionBoundObservationV1, RevisionBoundUnknownV1, + ScenarioId, ScenarioInput, SurfaceInputBinding, UnknownReasonId, canonicalize_observation_schema, prepare_observation, }; @@ -42,7 +42,7 @@ impl ObservationOwnerV1 for TestOwner { #[derive(Debug, Clone, PartialEq, Eq)] struct TestState { stream: ObservationStreamId, - schema: Vec, + schema: CanonicalObservationSchemaV1, owner: TestOwner, } @@ -154,13 +154,6 @@ fn paired_set(first: ([u8; 3], [u8; 3]), second: ([u8; 3], [u8; 3])) -> Observed ]) } -fn observed_set(state: &TestState) -> &ObservedScenarioSet { - state - .current_observation() - .expect("expected admitted observation") - .set() -} - fn revision_bound(state: &TestState) -> &RevisionBoundObservationV1 { state .current_observation() @@ -232,14 +225,16 @@ fn admission_preserves_correlated_tuples_without_cartesian_product() { )) .unwrap(); - let cases: Vec> = observed_set(&state) - .cases() - .iter() - .map(|case| { - case.bindings() + let observation = revision_bound(&state); + assert_eq!(observation.schema(), &[PORT_A, PORT_B]); + let cases: Vec> = (0..observation.physical_case_count()) + .map(|case_index| { + observation + .physical_values(case_index) + .unwrap() .iter() .copied() - .map(|binding| binding.value().bytes()) + .map(Srgb8::bytes) .collect() }) .collect(); @@ -269,27 +264,112 @@ fn canonicalization_ignores_declaration_order_and_groups_duplicate_physics() { right.apply(observed_update(STREAM, 1, second)).unwrap(); assert_eq!(left, right); - let set = observed_set(&left); - assert_eq!(set.cases().len(), 2); + let observation = revision_bound(&left); + assert_eq!(observation.schema(), &[PORT_A, PORT_B]); + assert_eq!(observation.physical_case_count(), 2); assert_eq!( - set.provenance(0).unwrap(), + observation.provenance(0).unwrap(), &[ScenarioId::new(3), ScenarioId::new(9)] ); - assert_eq!(set.provenance(1).unwrap(), &[ScenarioId::new(4)]); - let physical_bindings: Vec> = set - .cases() - .iter() - .map(|case| case.bindings().to_vec()) + assert_eq!(observation.provenance(1).unwrap(), &[ScenarioId::new(4)]); + let physical_values: Vec> = (0..observation.physical_case_count()) + .map(|case_index| observation.physical_values(case_index).unwrap().to_vec()) .collect(); assert_eq!( - physical_bindings, + physical_values, vec![ - vec![binding(PORT_A, [1, 2, 3]), binding(PORT_B, [4, 5, 6])], - vec![binding(PORT_A, [9, 8, 7]), binding(PORT_B, [6, 5, 4])], + vec![Srgb8::new([1, 2, 3]), Srgb8::new([4, 5, 6])], + vec![Srgb8::new([9, 8, 7]), Srgb8::new([6, 5, 4])], ] ); } +#[test] +fn revision_bound_clone_is_allocation_free_and_shares_all_canonical_backing() { + let mut state = TestState::new(STREAM, vec![PORT_B, PORT_A]).unwrap(); + state + .apply(observed_update( + STREAM, + 1, + scenarios([ + scenario(9, [binding(PORT_B, [4, 5, 6]), binding(PORT_A, [1, 2, 3])]), + scenario(3, [binding(PORT_A, [1, 2, 3]), binding(PORT_B, [4, 5, 6])]), + ]), + )) + .unwrap(); + + let observation = revision_bound(&state); + let backing_ptr = observation.backing_ptr_for_test(); + let schema_ptr = observation.schema_ptr_for_test(); + let (cloned, allocations) = crate::test_support::measured_allocations(|| observation.clone()); + + assert_eq!(allocations, 0); + assert_eq!(&cloned, observation); + assert_eq!(cloned.backing_ptr_for_test(), backing_ptr); + assert_eq!(cloned.schema_ptr_for_test(), schema_ptr); + assert_eq!(cloned.schema(), observation.schema()); + assert_eq!(cloned.physical_values(0), observation.physical_values(0)); + assert_eq!(cloned.provenance(0), observation.provenance(0)); +} + +#[test] +fn independent_equal_admissions_do_not_alias_observation_or_schema_backing() { + let first = scenarios([ + scenario(9, [binding(PORT_B, [4, 5, 6]), binding(PORT_A, [1, 2, 3])]), + scenario(3, [binding(PORT_A, [1, 2, 3]), binding(PORT_B, [4, 5, 6])]), + ]); + let second = scenarios([ + scenario(3, [binding(PORT_B, [4, 5, 6]), binding(PORT_A, [1, 2, 3])]), + scenario(9, [binding(PORT_A, [1, 2, 3]), binding(PORT_B, [4, 5, 6])]), + ]); + let mut left = TestState::new(STREAM, vec![PORT_B, PORT_A]).unwrap(); + let mut right = TestState::new(STREAM, vec![PORT_A, PORT_B]).unwrap(); + left.apply(observed_update(STREAM, 1, first)).unwrap(); + right.apply(observed_update(STREAM, 1, second)).unwrap(); + + let left = revision_bound(&left); + let right = revision_bound(&right); + assert_eq!(left, right); + assert_ne!(left.backing_ptr_for_test(), right.backing_ptr_for_test()); + assert_ne!(left.schema_ptr_for_test(), right.schema_ptr_for_test()); +} + +#[test] +fn schema_and_values_are_aligned_once_while_provenance_remains_complete() { + let mut state = TestState::new(STREAM, vec![PORT_B, PORT_A]).unwrap(); + state + .apply(observed_update( + STREAM, + 1, + scenarios([ + scenario(9, [binding(PORT_B, [4, 5, 6]), binding(PORT_A, [1, 2, 3])]), + scenario(4, [binding(PORT_A, [9, 8, 7]), binding(PORT_B, [6, 5, 4])]), + scenario(3, [binding(PORT_A, [1, 2, 3]), binding(PORT_B, [4, 5, 6])]), + ]), + )) + .unwrap(); + + let observation = revision_bound(&state); + let first_values: &[Srgb8] = observation.physical_values(0).unwrap(); + let second_values: &[Srgb8] = observation.physical_values(1).unwrap(); + assert_eq!(observation.schema(), &[PORT_A, PORT_B]); + assert_eq!( + first_values, + &[Srgb8::new([1, 2, 3]), Srgb8::new([4, 5, 6])] + ); + assert_eq!( + second_values, + &[Srgb8::new([9, 8, 7]), Srgb8::new([6, 5, 4])] + ); + assert_eq!( + observation.provenance(0), + Some(&[ScenarioId::new(3), ScenarioId::new(9)][..]) + ); + assert_eq!(observation.provenance(1), Some(&[ScenarioId::new(4)][..])); + assert_eq!(observation.physical_values(2), None); + assert_eq!(observation.provenance(2), None); +} + #[test] fn keyed_schema_is_intrinsic_to_revision_bound_observation_identity() { let mut left = TestState::new(STREAM, vec![PORT_A]).unwrap(); @@ -309,26 +389,24 @@ fn keyed_schema_is_intrinsic_to_revision_bound_observation_identity() { .unwrap(); assert_ne!(revision_bound(&left), revision_bound(&right)); + assert_eq!(revision_bound(&left).schema(), &[PORT_A]); + assert_eq!(revision_bound(&right).schema(), &[PORT_B]); assert_eq!( - revision_bound(&left).validate_surface_schema(&[PORT_A, PORT_B]), - Err(ObservationSchemaMismatchV1::new(0, 1, Some(PORT_B), None,)) + revision_bound(&left).physical_values(0), + Some(&[Srgb8::new([7, 8, 9])][..]) ); assert_eq!( - revision_bound(&left).validate_surface_schema(&[PORT_B]), - Err(ObservationSchemaMismatchV1::new( - 0, - 0, - Some(PORT_B), - Some(PORT_A), - )) + revision_bound(&right).physical_values(0), + Some(&[Srgb8::new([7, 8, 9])][..]) ); + let alternate_schema = canonicalize_observation_schema(vec![PORT_B]).unwrap(); let before = left.clone(); assert!(matches!( prepare_observation( &mut left.owner, STREAM, - &[PORT_B], + &alternate_schema, observed_update( STREAM, 1, @@ -342,21 +420,27 @@ fn keyed_schema_is_intrinsic_to_revision_bound_observation_identity() { } #[test] -fn lower_revision_is_rejected_before_malformed_payload_scan_and_never_moves_head() { +fn stream_precedes_full_scenario_admission_which_precedes_revision_checks() { let mut state = TestState::new(STREAM, vec![PORT_A, PORT_B]).unwrap(); state.apply(unknown_update(STREAM, 4, 1)).unwrap(); let before = state.clone(); - let malformed = scenarios([scenario(1, [binding(PORT_A, [1; 3])])]); + let malformed = || scenarios([scenario(1, [binding(PORT_A, [1; 3])])]); + assert_eq!( - state.apply(observed_update(STREAM, 2, malformed.clone())), - Err(ObservationError::RevisionOutOfOrder { - current: Revision::new(4), - incoming: Revision::new(2), + state.apply(observed_update( + ObservationStreamId::new(99), + 2, + malformed(), + )), + Err(ObservationError::StreamMismatch { + expected: STREAM, + actual: ObservationStreamId::new(99), }) ); assert_eq!(state, before); + assert_eq!( - state.apply(observed_update(STREAM, 5, malformed)), + state.apply(observed_update(STREAM, 2, malformed())), Err(ObservationError::MissingSurfaceInputBinding { scenario: ScenarioId::new(1), input: PORT_B, @@ -364,12 +448,32 @@ fn lower_revision_is_rejected_before_malformed_payload_scan_and_never_moves_head ); assert_eq!(state, before); - let corrected = scenarios([scenario( - 1, - [binding(PORT_A, [1; 3]), binding(PORT_B, [2; 3])], - )]); assert_eq!( - state.apply(observed_update(STREAM, 5, corrected)), + state.apply(observed_update(STREAM, 4, malformed())), + Err(ObservationError::MissingSurfaceInputBinding { + scenario: ScenarioId::new(1), + input: PORT_B, + }) + ); + assert_eq!(state, before); + + let valid = || { + scenarios([scenario( + 1, + [binding(PORT_A, [1; 3]), binding(PORT_B, [2; 3])], + )]) + }; + assert_eq!( + state.apply(observed_update(STREAM, 2, valid())), + Err(ObservationError::RevisionOutOfOrder { + current: Revision::new(4), + incoming: Revision::new(2), + }) + ); + assert_eq!(state, before); + + assert_eq!( + state.apply(observed_update(STREAM, 5, valid())), Ok(UpdateDisposition::Applied) ); assert!(state.current_observation().is_some()); @@ -427,13 +531,15 @@ fn observed_to_unknown_replaces_raw_payload_instead_of_duplicating_it() { )) .unwrap(); let old_revision = revision_bound(&state).revision(); - let old_bindings = revision_bound(&state).set().cases()[0].bindings().to_vec(); + let old_schema = revision_bound(&state).schema().to_vec(); + let old_values = revision_bound(&state).physical_values(0).unwrap().to_vec(); state.apply(unknown_update(STREAM, 2, 9)).unwrap(); assert!(state.current_observation().is_none()); assert!(matches!(state.head(), ObservationHeadViewV1::Unknown(_))); assert_eq!(old_revision, Revision::new(1)); - assert_eq!(old_bindings, vec![binding(PORT_A, [3, 4, 5])]); + assert_eq!(old_schema, vec![PORT_A]); + assert_eq!(old_values, vec![Srgb8::new([3, 4, 5])]); } #[test] @@ -459,6 +565,36 @@ fn same_revision_exact_payload_is_idempotent_and_conflict_is_rejected() { assert_eq!(state, before); } +#[test] +fn same_revision_permuted_replay_is_idempotent_without_replacing_backing() { + let first = scenarios([ + scenario(9, [binding(PORT_B, [4, 5, 6]), binding(PORT_A, [1, 2, 3])]), + scenario(4, [binding(PORT_A, [9, 8, 7]), binding(PORT_B, [6, 5, 4])]), + scenario(3, [binding(PORT_A, [1, 2, 3]), binding(PORT_B, [4, 5, 6])]), + ]); + let replay = scenarios([ + scenario(3, [binding(PORT_B, [4, 5, 6]), binding(PORT_A, [1, 2, 3])]), + scenario(9, [binding(PORT_A, [1, 2, 3]), binding(PORT_B, [4, 5, 6])]), + scenario(4, [binding(PORT_B, [6, 5, 4]), binding(PORT_A, [9, 8, 7])]), + ]); + let mut state = TestState::new(STREAM, vec![PORT_B, PORT_A]).unwrap(); + assert_eq!( + state.apply(observed_update(STREAM, 7, first)), + Ok(UpdateDisposition::Applied) + ); + let before = state.clone(); + let backing_ptr = revision_bound(&state).backing_ptr_for_test(); + let schema_ptr = revision_bound(&state).schema_ptr_for_test(); + + assert_eq!( + state.apply(observed_update(STREAM, 7, replay)), + Ok(UpdateDisposition::Idempotent) + ); + assert_eq!(state, before); + assert_eq!(revision_bound(&state).backing_ptr_for_test(), backing_ptr); + assert_eq!(revision_bound(&state).schema_ptr_for_test(), schema_ptr); +} + #[test] fn lower_revision_and_foreign_stream_are_atomic_rejections() { let mut state = TestState::new(STREAM, vec![PORT_A]).unwrap(); @@ -532,7 +668,18 @@ fn two_streams_have_independent_watermarks_and_same_physics() { left.apply(observed_update(STREAM, 5, set.clone())).unwrap(); right.apply(observed_update(other, 1, set)).unwrap(); - assert_eq!(revision_bound(&left).set(), revision_bound(&right).set()); + assert_eq!( + revision_bound(&left).schema(), + revision_bound(&right).schema() + ); + assert_eq!( + revision_bound(&left).physical_values(0), + revision_bound(&right).physical_values(0) + ); + assert_eq!( + revision_bound(&left).provenance(0), + revision_bound(&right).provenance(0) + ); assert_ne!( revision_bound(&left).stream(), revision_bound(&right).stream() diff --git a/crates/labcolors-core/src/output_projection.rs b/crates/labcolors-core/src/output_projection.rs new file mode 100644 index 00000000..58e6c5a4 --- /dev/null +++ b/crates/labcolors-core/src/output_projection.rs @@ -0,0 +1,543 @@ +//! Private, release-bound output projection from one modeled LCS occurrence. +//! +//! This module deliberately admits one narrow edge only: +//! +//! ```text +//! modeled IEC sRGB8 signal +//! -> replayed tristimulus +//! -> the same context-bound LCS occurrence +//! -> solid CSS Color 4 `oklch(...)` + replayable certificate +//! ``` +//! +//! An output projection is neither an appearance view nor a pairwise +//! difference calibration. The nominal release identifiers below therefore +//! cannot be substituted for one another. No alpha/composition, inverse, +//! output-gamut transform, P3 path or perceptual metric is admitted by this +//! slice. + +use crate::lcs_occurrence::{ + ColorSignal, HueAngle, HueState, LcsOccurrence, ModeledTristimulusDerivationV1, + ModeledTristimulusProvenanceV1, NumericDomainError, OKLAB_VIEW_RELEASE_V1, OklabViewReleaseId, + TristimulusDomainErrorV1, TristimulusSample, +}; +use crate::spaces::oklab::xyz_d65_to_oklab_v1; + +/// Formula, policy and operation-order identity of an output projection. +/// +/// The source qualifier is intentional: this release can re-express only an +/// occurrence whose exact modeled IEC sRGB8 provenance is supplied and +/// replayed. It does not claim an inverse rendering solution for arbitrary +/// XYZ occurrences. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum OutputProjectionReleaseIdV1 { + CssColor4OklchD65FromModeledIec61966Srgb8SolidV1, +} + +impl OutputProjectionReleaseIdV1 { + pub(crate) const fn key(self) -> &'static str { + match self { + Self::CssColor4OklchD65FromModeledIec61966Srgb8SolidV1 => { + "css-color-4-oklch-d65-from-modeled-iec61966-srgb8-solid-v1" + } + } + } +} + +pub(crate) const CSS_COLOR_4_OKLCH_D65_FROM_MODELED_SRGB8_SOLID_V1: OutputProjectionReleaseIdV1 = + OutputProjectionReleaseIdV1::CssColor4OklchD65FromModeledIec61966Srgb8SolidV1; + +/// No pairwise difference calibration is admitted by #441-A. +/// +/// Keeping this as a nominal, uninhabited type makes absence executable: code +/// cannot mint a distance result, alias an unrelated selector or pass +/// an output/appearance release where a calibration release is required. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum DifferenceCalibrationReleaseIdV1 {} + +impl DifferenceCalibrationReleaseIdV1 { + #[allow(dead_code)] + pub(crate) const fn key(self) -> &'static str { + match self {} + } +} + +/// Polar view derived from the registered rectangular Oklab appearance view. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum OklchViewReleaseId { + PolarFromOttosson20210125OklabV1, +} + +impl OklchViewReleaseId { + pub(crate) const fn key(self) -> &'static str { + match self { + Self::PolarFromOttosson20210125OklabV1 => "polar-from-ottosson-2021-01-25-oklab-v1", + } + } +} + +pub(crate) const OKLCH_VIEW_RELEASE_V1: OklchViewReleaseId = + OklchViewReleaseId::PolarFromOttosson20210125OklabV1; + +/// Finite binary64 appearance coordinate with canonical positive zero. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +struct FiniteOklchCoordinateV1(u64); + +impl FiniteOklchCoordinateV1 { + fn new(value: f64) -> Result { + if !value.is_finite() { + return Err(NumericDomainError::NonFinite); + } + Ok(Self(if value == 0.0 { + 0.0_f64.to_bits() + } else { + value.to_bits() + })) + } + + fn get(self) -> f64 { + f64::from_bits(self.0) + } +} + +/// Immutable polar Oklch view of the output projection's admitted occurrence. +/// +/// `UndefinedExact` remains a distinct appearance state. Mapping it to a CSS +/// numeric token happens later under +/// [`CssOklchHueSerializationReleaseIdV1`], never inside this view. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) struct OklchViewV1 { + release: OklchViewReleaseId, + l: FiniteOklchCoordinateV1, + c: FiniteOklchCoordinateV1, + hue: HueState, +} + +impl OklchViewV1 { + pub(crate) const fn release(self) -> OklchViewReleaseId { + self.release + } + + pub(crate) fn l(self) -> f64 { + self.l.get() + } + + pub(crate) fn c(self) -> f64 { + self.c.get() + } + + pub(crate) const fn hue(self) -> HueState { + self.hue + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum OklchViewFieldV1 { + OklabL, + OklabA, + OklabB, + OklchChroma, + OklchHue, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct OklchViewDerivationErrorV1 { + release: OklchViewReleaseId, + field: OklchViewFieldV1, + reason: NumericDomainError, +} + +impl OklchViewDerivationErrorV1 { + pub(crate) const fn release(self) -> OklchViewReleaseId { + self.release + } + + pub(crate) const fn field(self) -> OklchViewFieldV1 { + self.field + } + + pub(crate) const fn reason(self) -> NumericDomainError { + self.reason + } +} + +fn oklch_view_error( + field: OklchViewFieldV1, + reason: NumericDomainError, +) -> OklchViewDerivationErrorV1 { + OklchViewDerivationErrorV1 { + release: OKLCH_VIEW_RELEASE_V1, + field, + reason, + } +} + +/// Derive the named polar view before any CSS serialization policy runs. +/// +/// Hue is derived solely from the rectangular coordinates. Encoded-source +/// facts belong to serialization policy and cannot change this view. +fn derive_oklch_view_v1(oklab: [f64; 3]) -> Result { + let [l, a, b] = oklab; + let l = FiniteOklchCoordinateV1::new(l) + .map_err(|reason| oklch_view_error(OklchViewFieldV1::OklabL, reason))?; + FiniteOklchCoordinateV1::new(a) + .map_err(|reason| oklch_view_error(OklchViewFieldV1::OklabA, reason))?; + FiniteOklchCoordinateV1::new(b) + .map_err(|reason| oklch_view_error(OklchViewFieldV1::OklabB, reason))?; + let c = FiniteOklchCoordinateV1::new(a.hypot(b)) + .map_err(|reason| oklch_view_error(OklchViewFieldV1::OklchChroma, reason))?; + let hue = if a == 0.0 && b == 0.0 { + HueState::UndefinedExact + } else { + let degrees = b.atan2(a).to_degrees(); + let canonical = if degrees < 0.0 { + degrees + 360.0 + } else { + degrees + }; + HueState::Defined( + HueAngle::new(canonical) + .map_err(|reason| oklch_view_error(OklchViewFieldV1::OklchHue, reason))?, + ) + }; + Ok(OklchViewV1 { + release: OKLCH_VIEW_RELEASE_V1, + l, + c, + hue, + }) +} + +/// Exact decimal serialization policy carried by the projection certificate. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum CssOklchNumberEncodingReleaseIdV1 { + LPercent5C6Hue3V1, +} + +/// Hue serialization is output syntax, not occurrence hue identity. +/// +/// Exact encoded greys and an exact rectangular Oklab origin serialize as the +/// harmless numeric CSS convention `0`. This never changes an appearance +/// view's [`crate::lcs_occurrence::HueState`] to `Defined(0°)` and introduces no +/// tolerance or perceptual-achromaticity threshold. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum CssOklchHueSerializationReleaseIdV1 { + ExactSourceGreyOrRectangularOriginToZeroV1, +} + +/// This projection release performs no explicit output-gamut mapping step. +/// +/// The name deliberately makes no identity or round-trip claim about a later +/// inverse conversion, quantizer or host renderer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum OutputGamutTreatmentV1 { + NoExplicitProjectionGamutMapV1, +} + +/// A modeled derivation and the occurrence which claims that exact sample. +/// +/// Construction is sealed so a caller cannot attach convenient source bytes to +/// an unrelated XYZ occurrence. Context is retained inside the occurrence and +/// therefore remains part of certificate identity even though this output edge +/// uses only the occurrence's stimulus coordinates. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ProjectionSourceV1 { + occurrence: LcsOccurrence, + modeled: ModeledTristimulusDerivationV1, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ProjectionSourceFormationErrorV1 { + ProvenanceReplayFailed(TristimulusDomainErrorV1), + RecordedSampleDoesNotReplay { + recorded: TristimulusSample, + replayed: TristimulusSample, + }, + OccurrenceSampleMismatch { + occurrence: TristimulusSample, + modeled: TristimulusSample, + }, +} + +impl ProjectionSourceV1 { + pub(crate) fn bind( + occurrence: LcsOccurrence, + modeled: ModeledTristimulusDerivationV1, + ) -> Result { + let source = Self { + occurrence, + modeled, + }; + source.verify()?; + Ok(source) + } + + fn verify(self) -> Result<(), ProjectionSourceFormationErrorV1> { + let replayed = self + .modeled + .replay() + .map_err(ProjectionSourceFormationErrorV1::ProvenanceReplayFailed)?; + let recorded = self.modeled.sample(); + if replayed != recorded { + return Err( + ProjectionSourceFormationErrorV1::RecordedSampleDoesNotReplay { + recorded, + replayed, + }, + ); + } + let occurrence = self.occurrence.sample(); + if occurrence != recorded { + return Err(ProjectionSourceFormationErrorV1::OccurrenceSampleMismatch { + occurrence, + modeled: recorded, + }); + } + Ok(()) + } + + pub(crate) const fn occurrence(self) -> LcsOccurrence { + self.occurrence + } + + pub(crate) const fn modeled(self) -> ModeledTristimulusDerivationV1 { + self.modeled + } + + pub(crate) const fn provenance(self) -> ModeledTristimulusProvenanceV1 { + self.modeled.provenance() + } + + pub(crate) const fn signal(self) -> ColorSignal { + self.provenance().source_signal() + } +} + +/// The release choice is made before any coordinates or output bytes exist. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct OutputProjectionRequestV1 { + source: ProjectionSourceV1, + release: OutputProjectionReleaseIdV1, +} + +impl OutputProjectionRequestV1 { + pub(crate) const fn new( + source: ProjectionSourceV1, + release: OutputProjectionReleaseIdV1, + ) -> Self { + Self { source, release } + } + + pub(crate) const fn source(self) -> ProjectionSourceV1 { + self.source + } + + pub(crate) const fn release(self) -> OutputProjectionReleaseIdV1 { + self.release + } +} + +/// A solid CSS Color 4 value. There is intentionally no alpha field or +/// constructor accepting opacity. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CssColor4OklchD65SolidV1(String); + +impl CssColor4OklchD65SolidV1 { + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum OutputProjectionFieldV1 { + OklchLightness, + OklchHueState, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum OutputProjectionNumericErrorV1 { + LightnessOutsideSourceDomain, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum OutputProjectionErrorV1 { + Source(ProjectionSourceFormationErrorV1), + OklchView(OklchViewDerivationErrorV1), + Numeric { + release: OutputProjectionReleaseIdV1, + field: OutputProjectionFieldV1, + reason: OutputProjectionNumericErrorV1, + }, + UnsupportedHueState { + release: OutputProjectionReleaseIdV1, + field: OutputProjectionFieldV1, + hue: HueState, + }, +} + +/// All inputs and versioned policies needed to replay one projection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct OutputProjectionCertificateV1 { + source: ProjectionSourceV1, + release: OutputProjectionReleaseIdV1, + oklab_release: OklabViewReleaseId, + oklch_release: OklchViewReleaseId, + number_encoding: CssOklchNumberEncodingReleaseIdV1, + hue_serialization: CssOklchHueSerializationReleaseIdV1, + gamut_treatment: OutputGamutTreatmentV1, +} + +impl OutputProjectionCertificateV1 { + pub(crate) const fn source(self) -> ProjectionSourceV1 { + self.source + } + + pub(crate) const fn source_occurrence(self) -> LcsOccurrence { + self.source.occurrence() + } + + pub(crate) const fn source_provenance(self) -> ModeledTristimulusProvenanceV1 { + self.source.provenance() + } + + pub(crate) const fn source_signal(self) -> ColorSignal { + self.source.signal() + } + + pub(crate) const fn release(self) -> OutputProjectionReleaseIdV1 { + self.release + } + + pub(crate) const fn oklab_release(self) -> OklabViewReleaseId { + self.oklab_release + } + + pub(crate) const fn oklch_release(self) -> OklchViewReleaseId { + self.oklch_release + } + + pub(crate) const fn number_encoding(self) -> CssOklchNumberEncodingReleaseIdV1 { + self.number_encoding + } + + pub(crate) const fn hue_serialization(self) -> CssOklchHueSerializationReleaseIdV1 { + self.hue_serialization + } + + pub(crate) const fn gamut_treatment(self) -> OutputGamutTreatmentV1 { + self.gamut_treatment + } + + /// Re-run source provenance, view math and serialization under the same + /// release. Equality with the certified value is the byte replay check. + pub(crate) fn replay(self) -> Result { + project_output_v1(OutputProjectionRequestV1::new(self.source, self.release)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct OutputProjectionV1 { + oklch_view: OklchViewV1, + value: CssColor4OklchD65SolidV1, + certificate: OutputProjectionCertificateV1, +} + +impl OutputProjectionV1 { + pub(crate) const fn oklch_view(&self) -> OklchViewV1 { + self.oklch_view + } + + pub(crate) fn value(&self) -> &CssColor4OklchD65SolidV1 { + &self.value + } + + pub(crate) const fn certificate(&self) -> OutputProjectionCertificateV1 { + self.certificate + } +} + +fn numeric_error( + release: OutputProjectionReleaseIdV1, + field: OutputProjectionFieldV1, + reason: OutputProjectionNumericErrorV1, +) -> OutputProjectionErrorV1 { + OutputProjectionErrorV1::Numeric { + release, + field, + reason, + } +} + +fn project_css_color4_oklch_d65_from_modeled_srgb8_solid_v1( + source: ProjectionSourceV1, + release: OutputProjectionReleaseIdV1, +) -> Result { + source.verify().map_err(OutputProjectionErrorV1::Source)?; + + // The occurrence sample, not the encoded source channels, owns appearance + // geometry. Provenance is used only to prove that this narrow output + // release may serialize the occurrence under its registered policies. + let oklab = xyz_d65_to_oklab_v1(source.occurrence().sample().xyz()); + let source_srgb8 = source.signal().srgb8(); + let oklch_view = derive_oklch_view_v1(oklab).map_err(OutputProjectionErrorV1::OklchView)?; + let l = oklch_view.l(); + if !(0.0..=1.0).contains(&l) { + return Err(numeric_error( + release, + OutputProjectionFieldV1::OklchLightness, + OutputProjectionNumericErrorV1::LightnessOutsideSourceDomain, + )); + } + + let hue_degrees = if source_srgb8.is_achromatic() { + 0.0 + } else { + match oklch_view.hue() { + HueState::Defined(angle) => angle.degrees(), + HueState::UndefinedExact => 0.0, + hue @ HueState::PowerlessBy(_) => { + return Err(OutputProjectionErrorV1::UnsupportedHueState { + release, + field: OutputProjectionFieldV1::OklchHueState, + hue, + }); + } + } + }; + + let value = CssColor4OklchD65SolidV1(format!( + "oklch({:.5}% {:.6} {:.3})", + l * 100.0, + oklch_view.c(), + hue_degrees, + )); + let certificate = OutputProjectionCertificateV1 { + source, + release, + oklab_release: OKLAB_VIEW_RELEASE_V1, + oklch_release: oklch_view.release(), + number_encoding: CssOklchNumberEncodingReleaseIdV1::LPercent5C6Hue3V1, + hue_serialization: + CssOklchHueSerializationReleaseIdV1::ExactSourceGreyOrRectangularOriginToZeroV1, + gamut_treatment: OutputGamutTreatmentV1::NoExplicitProjectionGamutMapV1, + }; + Ok(OutputProjectionV1 { + oklch_view, + value, + certificate, + }) +} + +/// Execute exactly the release selected in the request. There is no +/// result-dependent release selection or fallback. +pub(crate) fn project_output_v1( + request: OutputProjectionRequestV1, +) -> Result { + match request.release() { + OutputProjectionReleaseIdV1::CssColor4OklchD65FromModeledIec61966Srgb8SolidV1 => { + project_css_color4_oklch_d65_from_modeled_srgb8_solid_v1( + request.source(), + request.release(), + ) + } + } +} diff --git a/crates/labcolors-core/src/output_projection_tests.rs b/crates/labcolors-core/src/output_projection_tests.rs new file mode 100644 index 00000000..b3e19d1a --- /dev/null +++ b/crates/labcolors-core/src/output_projection_tests.rs @@ -0,0 +1,230 @@ +use std::any::TypeId; + +use crate::Srgb8; +use crate::lcs_occurrence::{ + AdaptingLuminanceCdM2, AppearanceContextId, AppearanceContextSchemaReleaseId, + BackgroundLuminanceRatio, ColorSignal, IEC_SRGB_D65_XYZ_FRAME_V1, LcsOccurrence, + ModeledTristimulusDerivationV1, OKLAB_VIEW_RELEASE_V1, SurroundProfileId, + derive_modeled_tristimulus_v1, +}; +use crate::output_projection::{ + CSS_COLOR_4_OKLCH_D65_FROM_MODELED_SRGB8_SOLID_V1, CssOklchHueSerializationReleaseIdV1, + CssOklchNumberEncodingReleaseIdV1, DifferenceCalibrationReleaseIdV1, OKLCH_VIEW_RELEASE_V1, + OutputGamutTreatmentV1, OutputProjectionErrorV1, OutputProjectionReleaseIdV1, + OutputProjectionRequestV1, OutputProjectionV1, ProjectionSourceFormationErrorV1, + ProjectionSourceV1, project_output_v1, +}; +use crate::spaces::oklab::oklab_to_srgb_linear; +use crate::spaces::srgb::srgb8_from_linear; + +fn context(adapting_luminance_cd_m2: f64) -> AppearanceContextId { + AppearanceContextId::from_inputs( + AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1, + IEC_SRGB_D65_XYZ_FRAME_V1, + AdaptingLuminanceCdM2::try_new(adapting_luminance_cd_m2).unwrap(), + BackgroundLuminanceRatio::try_new(0.2).unwrap(), + SurroundProfileId::AverageV1, + ) +} + +fn modeled(bytes: [u8; 3]) -> ModeledTristimulusDerivationV1 { + derive_modeled_tristimulus_v1(ColorSignal::from_srgb8(Srgb8::new(bytes))).unwrap() +} + +fn source(bytes: [u8; 3], adapting_luminance_cd_m2: f64) -> ProjectionSourceV1 { + let modeled = modeled(bytes); + let occurrence = + LcsOccurrence::in_context(modeled.sample(), context(adapting_luminance_cd_m2)).unwrap(); + ProjectionSourceV1::bind(occurrence, modeled).unwrap() +} + +fn project(bytes: [u8; 3], adapting_luminance_cd_m2: f64) -> OutputProjectionV1 { + project_output_v1(OutputProjectionRequestV1::new( + source(bytes, adapting_luminance_cd_m2), + CSS_COLOR_4_OKLCH_D65_FROM_MODELED_SRGB8_SOLID_V1, + )) + .unwrap() +} + +fn difference_release_is_uninhabited(value: DifferenceCalibrationReleaseIdV1) -> ! { + match value {} +} + +#[test] +fn release_kinds_are_nominal_and_difference_registry_is_empty() { + assert_ne!( + TypeId::of::(), + TypeId::of::(), + ); + let _: fn(DifferenceCalibrationReleaseIdV1) -> ! = difference_release_is_uninhabited; + assert_eq!( + CSS_COLOR_4_OKLCH_D65_FROM_MODELED_SRGB8_SOLID_V1.key(), + "css-color-4-oklch-d65-from-modeled-iec61966-srgb8-solid-v1", + ); +} + +#[test] +fn projector_signature_has_no_alpha_metric_or_fallback_input() { + let projector: fn( + OutputProjectionRequestV1, + ) -> Result = project_output_v1; + assert!( + projector(OutputProjectionRequestV1::new( + source([0x44, 0x88, 0xCC], 64.0), + CSS_COLOR_4_OKLCH_D65_FROM_MODELED_SRGB8_SOLID_V1, + )) + .is_ok() + ); +} + +#[test] +fn source_binding_rejects_bytes_from_an_unrelated_modeled_derivation() { + let occurrence_model = modeled([0x44, 0x88, 0xCC]); + let unrelated_model = modeled([0xCC, 0x88, 0x44]); + let occurrence = LcsOccurrence::in_context(occurrence_model.sample(), context(64.0)).unwrap(); + + assert_eq!( + ProjectionSourceV1::bind(occurrence, unrelated_model), + Err(ProjectionSourceFormationErrorV1::OccurrenceSampleMismatch { + occurrence: occurrence_model.sample(), + modeled: unrelated_model.sample(), + }), + ); +} + +#[test] +fn certificate_replays_source_view_formula_and_exact_css_bytes() { + let projected = project([0x44, 0x88, 0xCC], 64.0); + let certificate = projected.certificate(); + + assert_eq!(certificate.replay().unwrap(), projected); + assert_eq!( + certificate.release(), + CSS_COLOR_4_OKLCH_D65_FROM_MODELED_SRGB8_SOLID_V1, + ); + assert_eq!(certificate.oklab_release(), OKLAB_VIEW_RELEASE_V1); + assert_eq!(certificate.oklch_release(), OKLCH_VIEW_RELEASE_V1); + assert_eq!( + certificate.oklch_release().key(), + "polar-from-ottosson-2021-01-25-oklab-v1", + ); + assert_eq!( + certificate.number_encoding(), + CssOklchNumberEncodingReleaseIdV1::LPercent5C6Hue3V1, + ); + assert_eq!( + certificate.hue_serialization(), + CssOklchHueSerializationReleaseIdV1::ExactSourceGreyOrRectangularOriginToZeroV1, + ); + assert_eq!( + certificate.gamut_treatment(), + OutputGamutTreatmentV1::NoExplicitProjectionGamutMapV1, + ); + assert_eq!( + certificate.source_signal().srgb8().bytes(), + [0x44, 0x88, 0xCC] + ); + assert_eq!( + certificate.source_provenance(), + certificate.source().modeled().provenance(), + ); +} + +#[test] +fn occurrence_context_remains_in_certificate_identity_not_css_geometry() { + let first = project([0x44, 0x88, 0xCC], 32.0); + let second = project([0x44, 0x88, 0xCC], 64.0); + + assert_eq!(first.value(), second.value()); + assert_ne!(first.certificate(), second.certificate()); + assert_ne!( + first.certificate().source_occurrence(), + second.certificate().source_occurrence(), + ); +} + +#[test] +fn solid_css_has_no_alpha_and_exact_encoded_greys_use_the_release_zero_convention() { + for byte in u8::MIN..=u8::MAX { + let projected = project([byte; 3], 64.0); + let css = projected.value().as_str(); + let view = projected.oklch_view(); + match view.hue() { + crate::lcs_occurrence::HueState::UndefinedExact => assert_eq!(view.c(), 0.0), + crate::lcs_occurrence::HueState::Defined(_) => assert!(view.c() > 0.0), + crate::lcs_occurrence::HueState::PowerlessBy(_) => { + panic!("pure polar view introduced a powerless policy state") + } + } + assert!(css.starts_with("oklch(") && css.ends_with(')'), "{css}"); + assert!(!css.contains('/'), "solid release emitted alpha: {css}"); + assert!(css.ends_with(" 0.000)"), "grey hue policy drifted: {css}"); + assert!(!css.contains("-0."), "signed zero escaped: {css}"); + } + + assert!(matches!( + project([u8::MAX; 3], 64.0).oklch_view().hue(), + crate::lcs_occurrence::HueState::Defined(_), + )); +} + +fn parse_solid_css(css: &str) -> [f64; 3] { + let inner = css + .strip_prefix("oklch(") + .and_then(|value| value.strip_suffix(')')) + .expect("registered solid CSS shape"); + assert!(!inner.contains('/')); + let mut fields = inner.split_whitespace(); + let l = fields + .next() + .and_then(|value| value.strip_suffix('%')) + .expect("percentage lightness") + .parse::() + .unwrap() + / 100.0; + let c = fields.next().unwrap().parse::().unwrap(); + let h = fields.next().unwrap().parse::().unwrap(); + assert!(fields.next().is_none()); + [l, c, h] +} + +fn replay_css_through_existing_inverse_to_srgb8(css: &str) -> Srgb8 { + let [l, c, h] = parse_solid_css(css); + let (sin, cos) = h.to_radians().sin_cos(); + srgb8_from_linear(oklab_to_srgb_linear([l, c * cos, c * sin])) +} + +#[test] +fn registered_precision_replays_a_non_aligned_lattice_through_existing_inverse_clamp_and_round() { + // 16^3 points including both ends. This is a deterministic regression + // corpus, not a claim about every CSS implementation or all 16.7M inputs. + let steps: Vec = (0_u16..=255).step_by(17).map(|value| value as u8).collect(); + assert_eq!(steps.first(), Some(&0)); + assert_eq!(steps.last(), Some(&255)); + + for &red in &steps { + for &green in &steps { + for &blue in &steps { + let expected = Srgb8::new([red, green, blue]); + let projected = project(expected.bytes(), 64.0); + assert_eq!( + replay_css_through_existing_inverse_to_srgb8(projected.value().as_str()), + expected, + "CSS byte replay drifted: {}", + projected.value().as_str(), + ); + } + } + } +} + +#[test] +fn fixed_css_values_pin_formula_order_and_solid_serialization() { + for (bytes, expected) in [ + ([0x00, 0x00, 0x00], "oklch(0.00000% 0.000000 0.000)"), + ([0xFF, 0xFF, 0xFF], "oklch(100.00000% 0.000000 0.000)"), + ([0xFF, 0x00, 0x00], "oklch(62.79554% 0.257683 29.234)"), + ] { + assert_eq!(project(bytes, 64.0).value().as_str(), expected); + } +} diff --git a/crates/labcolors-core/src/pair.rs b/crates/labcolors-core/src/pair.rs index 83423c50..e19f9348 100644 --- a/crates/labcolors-core/src/pair.rs +++ b/crates/labcolors-core/src/pair.rs @@ -73,27 +73,22 @@ pub(crate) enum PairLabelRequirementV1 { Wcag22(Wcag22CriterionV1), } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, PartialEq)] enum PairSelectionEvidenceV1 { Unconstrained(PointwiseVerifiedSelectionV1), Wcag22(PointwiseVerifiedSelectionV1), } /// Полное fresh evidence одной выбранной Pair-кандидатуры. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, PartialEq)] pub(crate) struct VerifiedPairV1 { evidence: PairSelectionEvidenceV1, + execution: JointExecutionRecordV1, } impl VerifiedPairV1 { fn execution(&self) -> &JointExecutionRecordV1 { - let executions = match &self.evidence { - PairSelectionEvidenceV1::Unconstrained(evidence) => evidence.fresh_executions(), - PairSelectionEvidenceV1::Wcag22(evidence) => evidence.fresh_executions(), - }; - executions.first().unwrap_or_else(|| { - unreachable!("one selected Pair tuple over one case has one execution") - }) + &self.execution } pub(crate) fn ordinal(&self) -> CandidateOrdinalV1 { @@ -120,7 +115,7 @@ impl VerifiedPairV1 { } } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, PartialEq)] pub(crate) enum PairLoweringErrorV1 { Candidate(CandidateSetErrorV1), Program(JointProgramErrorV1), @@ -131,6 +126,7 @@ pub(crate) enum PairLoweringErrorV1 { WcagInfeasible(Box>), ExactRecheck(PointwiseSelectedRecheckErrorV1), WcagRecheck(PointwiseSelectedRecheckErrorV1), + InternalInvariant, } /// Материализовать fill occurrence без role-specific эвристики. @@ -196,10 +192,17 @@ pub(crate) fn select_label_candidates( }; let verified = feasible .select(policy) + .map_err(|failure| PairLoweringErrorV1::Policy(failure.reason()))? .recheck() .map_err(PairLoweringErrorV1::ExactRecheck)?; + let execution = verified + .fresh_executions() + .first() + .copied() + .ok_or(PairLoweringErrorV1::InternalInvariant)?; Ok(VerifiedPairV1 { evidence: PairSelectionEvidenceV1::Unconstrained(verified), + execution, }) } PairLabelRequirementV1::Wcag22(criterion) => { @@ -227,10 +230,17 @@ pub(crate) fn select_label_candidates( }; let verified = feasible .select(policy) + .map_err(|failure| PairLoweringErrorV1::Policy(failure.reason()))? .recheck() .map_err(PairLoweringErrorV1::WcagRecheck)?; + let execution = verified + .fresh_executions() + .first() + .copied() + .ok_or(PairLoweringErrorV1::InternalInvariant)?; Ok(VerifiedPairV1 { evidence: PairSelectionEvidenceV1::Wcag22(verified), + execution, }) } } diff --git a/crates/labcolors-core/src/point_support.rs b/crates/labcolors-core/src/point_support.rs index 15116897..d36106b9 100644 --- a/crates/labcolors-core/src/point_support.rs +++ b/crates/labcolors-core/src/point_support.rs @@ -26,8 +26,14 @@ use crate::numerics::{ NumericalEvidenceClassV2, NumericalFallbackStatusV1, NumericalProofIdV2, NumericalSiteIdV2, StableNumericalOutcomeV2, numerical_registry_v2, }; -use crate::observation::{ObservationSchemaMismatchV1, RevisionBoundObservationV1, ScenarioId}; -use crate::session::SessionObservationBindingPermitV1; +use crate::observation::{ + CanonicalObservationSchemaV1, ObservationError, ObservationSchemaMismatchV1, + RevisionBoundObservationV1, ScenarioId, canonicalize_observation_schema, +}; +use crate::session::{ + SessionDecision, SessionEvidenceV1, SessionObservationBindingPermitV1, SessionPlanV1, + private as session_private, +}; use crate::wcag22::{Wcag22CriterionV1, Wcag22MeasurementV1, measure_wcag22_srgb8}; const DROP_BASIS_POINTS_SCALE: u16 = 10_000; @@ -139,18 +145,18 @@ pub(crate) enum PointSupportCompileErrorV1 { NumericalRegistryInvariant, } -/// Private compiler output for one role table. It owns its canonical surface -/// schema, but preserves declared occurrence order because that order is the -/// client-owned packed ordinal and witness order. Prebound surface indices are -/// only a cache: runtime keyed bindings remain truth and every cached position -/// is checked against its exact port before use. +/// Private compiler output for one role table. It owns the canonical shared +/// surface schema, but preserves declared occurrence order because that order +/// is the client-owned packed ordinal and witness order. Runtime observations +/// share this exact schema backing, so prebound positions require no keyed +/// lookup or per-case schema scan. #[derive(Debug, PartialEq, Eq)] #[cfg_attr(test, derive(Clone))] pub(crate) struct CompiledPointSupportRecheckV1 { physical_program: PhysicalProgramIdentityV1, composition_profile: CompositionProfileV1, occurrences: Vec, - surface_schema: Vec, + surface_schema: CanonicalObservationSchemaV1, surface_indices: Vec, baselines: Vec>, } @@ -177,34 +183,39 @@ impl CompiledPointSupportRecheckV1 { return Err(PointSupportCompileErrorV1::InactivePlan); } - let mut paint_definitions = Vec::new(); - paint_definitions - .try_reserve_exact(occurrences.len()) - .map_err(|_| PointSupportCompileErrorV1::ResourceExhausted)?; - paint_definitions.extend(occurrences.iter().map(|occurrence| occurrence.paint)); - paint_definitions.sort_unstable_by_key(|paint| paint.id()); - if let Some(drift) = paint_definitions - .windows(2) - .find(|window| window[0].id() == window[1].id() && window[0] != window[1]) { - return Err(PointSupportCompileErrorV1::PaintDefinitionMismatch( - drift[0].id(), - )); + let mut paint_definitions = Vec::new(); + paint_definitions + .try_reserve_exact(occurrences.len()) + .map_err(|_| PointSupportCompileErrorV1::ResourceExhausted)?; + paint_definitions.extend(occurrences.iter().map(|occurrence| occurrence.paint)); + paint_definitions.sort_unstable_by_key(|paint| paint.id()); + if let Some(drift) = paint_definitions + .windows(2) + .find(|window| window[0].id() == window[1].id() && window[0] != window[1]) + { + return Err(PointSupportCompileErrorV1::PaintDefinitionMismatch( + drift[0].id(), + )); + } } - let mut occurrence_identities = Vec::new(); - occurrence_identities - .try_reserve_exact(occurrences.len()) - .map_err(|_| PointSupportCompileErrorV1::ResourceExhausted)?; - occurrence_identities.extend(occurrences.iter().map(|occurrence| occurrence.occurrence)); - occurrence_identities.sort_unstable(); - if let Some(duplicate) = occurrence_identities - .windows(2) - .find(|window| window[0] == window[1]) { - return Err(PointSupportCompileErrorV1::DuplicateOccurrence( - duplicate[0], - )); + let mut occurrence_identities = Vec::new(); + occurrence_identities + .try_reserve_exact(occurrences.len()) + .map_err(|_| PointSupportCompileErrorV1::ResourceExhausted)?; + occurrence_identities + .extend(occurrences.iter().map(|occurrence| occurrence.occurrence)); + occurrence_identities.sort_unstable(); + if let Some(duplicate) = occurrence_identities + .windows(2) + .find(|window| window[0] == window[1]) + { + return Err(PointSupportCompileErrorV1::DuplicateOccurrence( + duplicate[0], + )); + } } let actual_profile = PointOpacityOverSurfaceV1::composition_profile(); @@ -233,6 +244,13 @@ impl CompiledPointSupportRecheckV1 { .map_err(|_| PointSupportCompileErrorV1::SurfaceSchemaInvariant)?; surface_indices.push(index); } + let surface_schema = canonicalize_observation_schema(surface_schema).map_err(|error| { + if matches!(error, ObservationError::ResourceExhausted) { + PointSupportCompileErrorV1::ResourceExhausted + } else { + PointSupportCompileErrorV1::SurfaceSchemaInvariant + } + })?; let mut baselines = Vec::new(); baselines @@ -297,54 +315,26 @@ impl CompiledPointSupportRecheckV1 { } pub(crate) fn surface_schema(&self) -> &[SurfaceInputPortId] { - &self.surface_schema - } - - pub(crate) fn into_session_recheck(self) -> BoundPointSupportRecheckV1 { - let Self { - physical_program, - composition_profile, - occurrences, - surface_schema, - surface_indices, - baselines, - } = self; - BoundPointSupportRecheckV1 { - physical_program, - composition_profile, - occurrences, - surface_schema, - surface_indices, - baselines, - } + self.surface_schema.as_slice() } } -#[derive(Debug, PartialEq, Eq)] -#[cfg_attr(test, derive(Clone))] -pub(crate) struct BoundPointSupportRecheckV1 { - physical_program: PhysicalProgramIdentityV1, - composition_profile: CompositionProfileV1, - occurrences: Vec, - surface_schema: Vec, - surface_indices: Vec, - baselines: Vec>, -} +impl session_private::PlanSealed for CompiledPointSupportRecheckV1 {} -impl BoundPointSupportRecheckV1 { - pub(crate) const fn composition_profile(&self) -> CompositionProfileV1 { - self.composition_profile - } +impl SessionPlanV1 for CompiledPointSupportRecheckV1 { + type Verified = VerifiedPointSupportV1; + type Violation = PointSupportViolationV1; + type Error = PointSupportEvaluationErrorV1; - pub(crate) fn surface_schema(&self) -> &[SurfaceInputPortId] { + fn observation_schema(&self) -> &CanonicalObservationSchemaV1 { &self.surface_schema } - pub(crate) fn evaluate( - &self, + fn evaluate( + &mut self, observation: RevisionBoundObservationV1, _permit: SessionObservationBindingPermitV1, - ) -> Result { + ) -> Result, Self::Error> { let assessment = evaluate_bound_point_support(self, &observation)?; Ok(assessment.bind(observation)) } @@ -629,7 +619,10 @@ impl PointSupportAssessmentV1 { /// Bind by move only. There is no allocation, recomputation or other /// fallible work after the consuming evaluator has completed assessment. - fn bind(self, observation: RevisionBoundObservationV1) -> PointSupportDecisionV1 { + fn bind( + self, + observation: RevisionBoundObservationV1, + ) -> SessionDecision { let failed = self.has_failure(); let Self { physical_program, @@ -655,9 +648,9 @@ impl PointSupportAssessmentV1 { first_stability_failure_index, }; if failed { - PointSupportDecisionV1::Violation(PointSupportViolationV1(report)) + SessionDecision::Violation(PointSupportViolationV1(report)) } else { - PointSupportDecisionV1::Verified(VerifiedPointSupportV1(report)) + SessionDecision::Verified(VerifiedPointSupportV1(report)) } } } @@ -744,6 +737,14 @@ impl RevisionBoundPointSupportReportV1 { #[cfg_attr(test, derive(Clone))] pub(crate) struct VerifiedPointSupportV1(RevisionBoundPointSupportReportV1); +impl session_private::EvidenceSealed for VerifiedPointSupportV1 {} + +impl SessionEvidenceV1 for VerifiedPointSupportV1 { + fn observation(&self) -> &RevisionBoundObservationV1 { + self.report().observation() + } +} + impl VerifiedPointSupportV1 { pub(crate) const fn report(&self) -> &RevisionBoundPointSupportReportV1 { &self.0 @@ -754,19 +755,20 @@ impl VerifiedPointSupportV1 { #[cfg_attr(test, derive(Clone))] pub(crate) struct PointSupportViolationV1(RevisionBoundPointSupportReportV1); +impl session_private::EvidenceSealed for PointSupportViolationV1 {} + +impl SessionEvidenceV1 for PointSupportViolationV1 { + fn observation(&self) -> &RevisionBoundObservationV1 { + self.report().observation() + } +} + impl PointSupportViolationV1 { pub(crate) const fn report(&self) -> &RevisionBoundPointSupportReportV1 { &self.0 } } -#[derive(Debug, PartialEq)] -#[cfg_attr(test, derive(Clone))] -pub(crate) enum PointSupportDecisionV1 { - Verified(VerifiedPointSupportV1), - Violation(PointSupportViolationV1), -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct EnabledBaselineV1 { composition: SourceOverCertificateV1, @@ -779,7 +781,7 @@ struct EnabledBaselineV1 { } fn evaluate_bound_point_support( - plan: &BoundPointSupportRecheckV1, + plan: &CompiledPointSupportRecheckV1, observation: &RevisionBoundObservationV1, ) -> Result { if plan.occurrences.len() != plan.surface_indices.len() @@ -787,9 +789,12 @@ fn evaluate_bound_point_support( { return Err(PointSupportEvaluationErrorV1::CompiledPlanInvariant); } - observation - .validate_surface_schema(&plan.surface_schema) - .map_err(PointSupportEvaluationErrorV1::ObservationSchemaMismatch)?; + if !observation.shares_schema_backing_with(&plan.surface_schema) { + observation + .validate_surface_schema(plan.surface_schema.as_slice()) + .map_err(PointSupportEvaluationErrorV1::ObservationSchemaMismatch)?; + return Err(PointSupportEvaluationErrorV1::CompiledPlanInvariant); + } let cell_count = observation .physical_case_count() @@ -807,37 +812,27 @@ fn evaluate_bound_point_support( let mut first_required_failure_index = None; let mut first_stability_failure_index = None; - for (case_index, case) in observation.set().cases().iter().enumerate() { - for (occurrence_index, requirement) in plan.occurrences.iter().enumerate() { - let surface_index = *plan - .surface_indices - .get(occurrence_index) - .ok_or(PointSupportEvaluationErrorV1::CompiledPlanInvariant)?; - let baseline = plan - .baselines - .get(occurrence_index) - .ok_or(PointSupportEvaluationErrorV1::CompiledPlanInvariant)?; - let binding = case.bindings().get(surface_index).ok_or( + for case_index in 0..observation.physical_case_count() { + let values = observation + .physical_values(case_index) + .ok_or(PointSupportEvaluationErrorV1::CompiledPlanInvariant)?; + for (occurrence_index, ((requirement, surface_index), baseline)) in plan + .occurrences + .iter() + .zip(&plan.surface_indices) + .zip(&plan.baselines) + .enumerate() + { + let backdrop = values.get(*surface_index).copied().ok_or( PointSupportEvaluationErrorV1::ObservationSchemaMismatch( ObservationSchemaMismatchV1::new( case_index, - surface_index, + *surface_index, Some(requirement.surface), None, ), ), )?; - if binding.port() != requirement.surface { - return Err(PointSupportEvaluationErrorV1::ObservationSchemaMismatch( - ObservationSchemaMismatchV1::new( - case_index, - surface_index, - Some(requirement.surface), - Some(binding.port()), - ), - )); - } - let backdrop = binding.value(); let physical = PointOpacityOverSurfaceV1::evaluate_admitted( requirement.paint.source().bytes(), requirement.paint.opacity(), diff --git a/crates/labcolors-core/src/point_support_tests.rs b/crates/labcolors-core/src/point_support_tests.rs index b377db60..63b1b030 100644 --- a/crates/labcolors-core/src/point_support_tests.rs +++ b/crates/labcolors-core/src/point_support_tests.rs @@ -15,7 +15,7 @@ use crate::point_support::{ PointSupportStabilityAnchorV1, PointSupportStabilityAssessmentV1, PointSupportStabilityDecisionV1, PointSupportStabilityPolicyV1, }; -use crate::session::{PointSupportSessionStateV1, PointSupportSessionV1}; +use crate::session::{Session, SessionState}; use crate::wcag22::Wcag22CriterionV1; const STREAM: ObservationStreamId = ObservationStreamId::new(31); @@ -106,8 +106,8 @@ fn multi_paint_declared_order_and_direct_provenance_are_preserved() { assert_eq!(SURFACE_A.value(), 21); assert_eq!(OCCURRENCE_A.value(), 11); - let mut session = PointSupportSessionV1::new(STREAM, requirements); - let PointSupportSessionStateV1::Ready { current } = session + let mut session = Session::new(STREAM, requirements); + let SessionState::Ready { current } = session .update(observed_update( 1, [(9, vec![(SURFACE_A, [0; 3]), (SURFACE_B, [255; 3])])], @@ -142,6 +142,119 @@ fn multi_paint_declared_order_and_direct_provenance_are_preserved() { assert_eq!(cells[1].provenance(), &[ScenarioId::new(9)]); } +#[test] +fn duplicate_raw_scenarios_share_one_physical_case_without_cartesian_expansion() { + let requirements = compiled(vec![ + occurrence( + OCCURRENCE_A, + SURFACE_A, + paint(PAINT_A, [0; 3], 0.5), + Some([128; 3]), + PointSupportCriterionRequirementV1::NotRequested, + PointSupportStabilityPolicyV1::Disabled, + ), + occurrence( + OCCURRENCE_B, + SURFACE_B, + paint(PAINT_B, [255; 3], 0.5), + Some([128; 3]), + PointSupportCriterionRequirementV1::NotRequested, + PointSupportStabilityPolicyV1::Disabled, + ), + ]); + let mut session = Session::new(STREAM, requirements); + + crate::composition::reset_source_over_evaluation_count(); + let SessionState::Failed { cause, previous } = session + .update(observed_update( + 1, + [ + // Same complete physical tuple, deliberately repeated with + // non-canonical IDs and binding order. + (90, vec![(SURFACE_B, [0; 3]), (SURFACE_A, [255; 3])]), + (10, vec![(SURFACE_A, [255; 3]), (SURFACE_B, [0; 3])]), + // A second anti-correlated tuple must remain one whole case; + // it must not be crossed with either value from the first. + (50, vec![(SURFACE_B, [255; 3]), (SURFACE_A, [0; 3])]), + ], + )) + .unwrap() + else { + panic!("the second physical case violates both required exact identities"); + }; + assert!(previous.is_none()); + + let report = cause.report(); + assert_eq!(report.observation().physical_case_count(), 2); + assert_eq!( + report.observation().physical_values(0), + Some(&[Srgb8::new([0; 3]), Srgb8::new([255; 3])][..]) + ); + assert_eq!( + report.observation().physical_values(1), + Some(&[Srgb8::new([255; 3]), Srgb8::new([0; 3])][..]) + ); + assert_eq!( + report.observation().provenance(0), + Some(&[ScenarioId::new(50)][..]) + ); + assert_eq!( + report.observation().provenance(1), + Some(&[ScenarioId::new(10), ScenarioId::new(90)][..]) + ); + + let cells: Vec<_> = report.cells().collect(); + assert_eq!( + cells.len(), + 4, + "two cases times two occurrences, not six raw cells" + ); + assert_eq!( + crate::composition::source_over_evaluation_count(), + 4, + "compose exactly once per (unique physical case, occurrence)" + ); + + assert_eq!(cells[0].case_index(), 0); + assert_eq!(cells[0].occurrence(), OCCURRENCE_A); + assert_eq!(cells[0].composition().backdrop_rgb(), [0; 3]); + assert_eq!(cells[0].provenance(), &[ScenarioId::new(50)]); + assert!(matches!( + cells[0].exact(), + PointSupportExactAssessmentV1::RequiredFailure(_) + )); + assert_eq!(cells[1].case_index(), 0); + assert_eq!(cells[1].occurrence(), OCCURRENCE_B); + assert_eq!(cells[1].composition().backdrop_rgb(), [255; 3]); + assert_eq!(cells[1].provenance(), &[ScenarioId::new(50)]); + assert!(matches!( + cells[1].exact(), + PointSupportExactAssessmentV1::RequiredFailure(_) + )); + + for (cell, occurrence, backdrop) in [ + (cells[2], OCCURRENCE_A, [255; 3]), + (cells[3], OCCURRENCE_B, [0; 3]), + ] { + assert_eq!(cell.case_index(), 1); + assert_eq!(cell.occurrence(), occurrence); + assert_eq!(cell.composition().backdrop_rgb(), backdrop); + assert_eq!( + cell.provenance(), + &[ScenarioId::new(10), ScenarioId::new(90)] + ); + assert!(matches!( + cell.exact(), + PointSupportExactAssessmentV1::RequiredPass(_) + )); + } + assert_eq!( + report.exact_aggregate(), + PointSupportExactAggregateV1::RequiredFailure, + "one unique violating case fails the whole recheck" + ); +} + #[test] fn exact_wcag_and_stability_are_independent_axes_and_baseline_binds_once() { let drop_all = PointSupportDropFractionV1::try_from_basis_points(10_000).unwrap(); @@ -164,8 +277,8 @@ fn exact_wcag_and_stability_are_independent_axes_and_baseline_binds_once() { "the baseline is composed exactly once at compile/bind" ); - let mut session = PointSupportSessionV1::new(STREAM, requirements); - let PointSupportSessionStateV1::Failed { cause, previous } = session + let mut session = Session::new(STREAM, requirements); + let SessionState::Failed { cause, previous } = session .update(observed_update(1, [(44, vec![(SURFACE_A, [255; 3])])])) .unwrap() else { @@ -273,8 +386,8 @@ fn all_four_wcag_criterion_identities_survive_the_full_support_path() { }) .collect(), ); - let mut session = PointSupportSessionV1::new(STREAM, requirements); - let PointSupportSessionStateV1::Ready { current } = session + let mut session = Session::new(STREAM, requirements); + let SessionState::Ready { current } = session .update(observed_update(1, [(1, vec![(SURFACE_A, [255; 3])])])) .unwrap() else { @@ -335,8 +448,8 @@ fn wholly_inactive_plan_is_rejected_but_an_inactive_composition_cell_is_allowed( ) .expect("one active axis makes the whole full-support plan meaningful"); assert_eq!(mixed.surface_schema(), &[SURFACE_A, SURFACE_B]); - let mut mixed_session = PointSupportSessionV1::new(STREAM, mixed); - let PointSupportSessionStateV1::Ready { current } = mixed_session + let mut mixed_session = Session::new(STREAM, mixed); + let SessionState::Ready { current } = mixed_session .update(observed_update( 1, [(1, vec![(SURFACE_A, [17; 3]), (SURFACE_B, [3; 3])])], @@ -423,8 +536,8 @@ fn every_stability_anchor_survives_compile_evaluate_and_typed_evidence() { }) .collect(), ); - let mut session = PointSupportSessionV1::new(STREAM, requirements); - let PointSupportSessionStateV1::Ready { current } = session + let mut session = Session::new(STREAM, requirements); + let SessionState::Ready { current } = session .update(observed_update(1, [(91, vec![(SURFACE_A, [255; 3])])])) .unwrap() else { diff --git a/crates/labcolors-core/src/program_session.rs b/crates/labcolors-core/src/program_session.rs new file mode 100644 index 00000000..bfb21f81 --- /dev/null +++ b/crates/labcolors-core/src/program_session.rs @@ -0,0 +1,1285 @@ +//! Private generic point Program compiler and lowering path. +//! +//! The authored graph has no client/UI role vocabulary. Paints are physical +//! source-plus-straight-alpha programs, occurrences are modeled applications of +//! Paint to Surface, constraints declare assessments of those exact +//! occurrences, and outputs bind opaque slots back to Paints. The compiled +//! result owns only admitted, canonical topology; runtime observation, +//! lifecycle and terminal emission belong to the sole revision-bound Session. +//! Its current values are encoded point transport-only; they are not LCS +//! observation or evidence. + +use std::marker::PhantomData; +use std::rc::Rc; + +use crate::Srgb8; +use crate::appearance::{ + AdmittedAppearanceBindings, AppearanceBindings, AppearanceGraphSpec, AppearanceWorkspace, + BindingError, ColorInputId, CompileError, CompiledAppearanceGraph, CompiledOccurrenceSlotV1, + CompiledPaintSlotV1, EncodedPointPaintV1, OccurrenceId, OccurrenceSpec, OpacityInputId, + PaintId, PaintSpec, SurfaceId, SurfaceInputPortId, SurfaceSpec, +}; +use crate::composition::CompositionProfileV1; +use crate::constraints::{ + HardDecision, PointEvaluatorV1, PointInvocation, VisiblePointPassEvidence, + VisiblePointViolationEvidence, assess_visible_point_hard, +}; +use crate::observation::{ + CanonicalObservationSchemaV1, ObservationError, ObservationGroupId, + ObservationSchemaMismatchV1, ObservationStreamId, RevisionBoundObservationV1, + canonicalize_observation_schema, +}; +use crate::session::{ + Session, SessionDecision, SessionEvidenceV1, SessionObservationBindingPermitV1, SessionPlanV1, + private as session_private, +}; + +/// One immutable encoded colour binding owned by a [`Program`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ColorInput { + id: ColorInputId, + value: Srgb8, +} + +impl ColorInput { + pub const fn new(id: ColorInputId, value: Srgb8) -> Self { + Self { id, value } + } + + pub const fn id(self) -> ColorInputId { + self.id + } + + pub const fn value(self) -> Srgb8 { + self.value + } +} + +/// One immutable straight-alpha binding owned by a [`Program`]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct OpacityInput { + id: OpacityInputId, + value: f64, +} + +impl OpacityInput { + pub const fn new(id: OpacityInputId, value: f64) -> Self { + Self { id, value } + } + + pub const fn id(self) -> OpacityInputId { + self.id + } + + pub const fn value(self) -> f64 { + self.value + } +} + +/// Generic point Paint constructor algebra. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Paint { + Solid { + id: PaintId, + color: ColorInputId, + }, + Opacity { + id: PaintId, + source: PaintId, + opacity: OpacityInputId, + }, +} + +/// Generic point Surface constructor algebra. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Surface { + Input { + id: SurfaceId, + input: SurfaceInputPortId, + }, + FromOccurrence { + id: SurfaceId, + occurrence: OccurrenceId, + }, +} + +/// Closed mathematical composition profile set for this Program version. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CompositionProfile { + EncodedSrgb8SourceOverV1, +} + +/// The canonical application of one Paint to one Surface. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Occurrence { + id: OccurrenceId, + subject: PaintId, + against: SurfaceId, + composition: CompositionProfile, +} + +impl Occurrence { + pub const fn new( + id: OccurrenceId, + subject: PaintId, + against: SurfaceId, + composition: CompositionProfile, + ) -> Self { + Self { + id, + subject, + against, + composition, + } + } + + pub const fn id(self) -> OccurrenceId { + self.id + } + + pub const fn subject(self) -> PaintId { + self.subject + } + + pub const fn against(self) -> SurfaceId { + self.against + } + + pub const fn composition(self) -> CompositionProfile { + self.composition + } +} + +/// Opaque authored constraint identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ConstraintId(u32); + +impl ConstraintId { + pub const fn new(value: u32) -> Self { + Self(value) + } + + pub const fn value(self) -> u32 { + self.0 + } +} + +/// Opaque authored terminal output identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct OutputSlotId(u32); + +impl OutputSlotId { + pub const fn new(value: u32) -> Self { + Self(value) + } + + pub const fn value(self) -> u32 { + self.0 + } +} + +/// Type-level marker for a mandatory constraint. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HardModeV1 {} + +/// Type-level marker for a diagnostic-only constraint. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReportModeV1 {} + +/// One typed evaluator invocation over one exact visible occurrence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConstraintInvocation { + id: ConstraintId, + target: OccurrenceId, + invocation: Invocation, + mode: PhantomData Mode>, +} + +impl ConstraintInvocation { + pub const fn hard(id: ConstraintId, target: OccurrenceId, invocation: Invocation) -> Self { + Self { + id, + target, + invocation, + mode: PhantomData, + } + } +} + +impl ConstraintInvocation { + pub const fn report_only( + id: ConstraintId, + target: OccurrenceId, + invocation: Invocation, + ) -> Self { + Self { + id, + target, + invocation, + mode: PhantomData, + } + } +} + +impl ConstraintInvocation { + pub const fn id(&self) -> ConstraintId { + self.id + } + + pub const fn target(&self) -> OccurrenceId { + self.target + } + + pub const fn invocation(&self) -> &Invocation { + &self.invocation + } +} + +/// The two authored modality domains remain type-separated until compilation. +#[derive(Debug, PartialEq, Eq)] +pub struct ConstraintSet { + hard: Vec>, + report_only: Vec>, +} + +impl ConstraintSet { + pub fn new( + hard: Vec>, + report_only: Vec>, + ) -> Self { + Self { hard, report_only } + } + + pub fn hard(&self) -> &[ConstraintInvocation] { + &self.hard + } + + pub fn report_only(&self) -> &[ConstraintInvocation] { + &self.report_only + } + + fn is_empty(&self) -> bool { + self.hard.is_empty() && self.report_only.is_empty() + } + + fn checked_len(&self) -> Option { + self.hard.len().checked_add(self.report_only.len()) + } +} + +/// Compile-time binding from one terminal slot to one Paint. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OutputBinding { + output: OutputSlotId, + paint: PaintId, +} + +impl OutputBinding { + pub const fn new(output: OutputSlotId, paint: PaintId) -> Self { + Self { output, paint } + } + + pub const fn output(self) -> OutputSlotId { + self.output + } + + pub const fn paint(self) -> PaintId { + self.paint + } +} + +/// One compile-time atomic correlation boundary for this Program epoch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ObservationGroup { + id: ObservationGroupId, + surface_input_ports: Vec, +} + +impl ObservationGroup { + pub const fn new(id: ObservationGroupId, surface_input_ports: Vec) -> Self { + Self { + id, + surface_input_ports, + } + } + + pub const fn id(&self) -> ObservationGroupId { + self.id + } + + pub fn surface_input_ports(&self) -> &[SurfaceInputPortId] { + &self.surface_input_ports + } +} + +/// Immutable generic point Program. +pub struct Program +where + Evaluation: PointEvaluatorV1, + PointInvocation: Copy, +{ + colors: Vec, + observation_group: ObservationGroup, + opacities: Vec, + paints: Vec, + surfaces: Vec, + occurrences: Vec, + constraints: ConstraintSet>, + outputs: Vec, + evaluator: Evaluation, +} + +impl Program +where + Evaluation: PointEvaluatorV1, + PointInvocation: Copy, +{ + #[allow(clippy::too_many_arguments)] + pub fn new( + colors: Vec, + observation_group: ObservationGroup, + opacities: Vec, + paints: Vec, + surfaces: Vec, + occurrences: Vec, + constraints: ConstraintSet>, + outputs: Vec, + evaluator: Evaluation, + ) -> Self { + Self { + colors, + observation_group, + opacities, + paints, + surfaces, + occurrences, + constraints, + outputs, + evaluator, + } + } + + pub fn compile(self) -> Result, ProgramCompileError> { + prepare_program(self).map(|epoch| CompiledProgram { + epoch: Rc::new(epoch), + }) + } +} + +/// Atomic compile failure. No executable partial graph escapes. +#[derive(Debug, PartialEq, Eq)] +pub enum ProgramCompileError { + DuplicateColorInput { + input: ColorInputId, + }, + DuplicateOpacityInput { + input: OpacityInputId, + }, + DuplicateSurfaceInputPort { + input: SurfaceInputPortId, + }, + DuplicatePaint { + paint: PaintId, + }, + DuplicateSurface { + surface: SurfaceId, + }, + DuplicateOccurrence { + occurrence: OccurrenceId, + }, + MissingPaintColorInput { + paint: PaintId, + input: ColorInputId, + }, + MissingPaintSource { + paint: PaintId, + source: PaintId, + }, + MissingPaintOpacityInput { + paint: PaintId, + input: OpacityInputId, + }, + MissingSurfaceInputPort { + surface: SurfaceId, + input: SurfaceInputPortId, + }, + MissingSurfaceOccurrence { + surface: SurfaceId, + occurrence: OccurrenceId, + }, + MissingOccurrencePaint { + occurrence: OccurrenceId, + paint: PaintId, + }, + MissingOccurrenceBackdrop { + occurrence: OccurrenceId, + surface: SurfaceId, + }, + PaintCycle { + paints: Vec, + }, + RenderCycle { + surfaces: Vec, + occurrences: Vec, + }, + OpacityOutOfDomain { + input: OpacityInputId, + }, + EmptyObservationGroup { + group: ObservationGroupId, + }, + EmptyOccurrenceSet, + EmptyConstraintSet, + EmptyOutputSet, + DuplicateConstraint { + constraint: ConstraintId, + }, + MissingConstraintOccurrence { + constraint: ConstraintId, + occurrence: OccurrenceId, + }, + DuplicateOutputSlot { + output: OutputSlotId, + }, + MissingOutputPaint { + output: OutputSlotId, + paint: PaintId, + }, + ResourceExhausted, + InternalInvariant, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CompiledConstraintModeV1 { + Hard, + ReportOnly, +} + +struct CompiledPointConstraint { + id: ConstraintId, + target_id: OccurrenceId, + target: CompiledOccurrenceSlotV1, + mode: CompiledConstraintModeV1, + invocation: Invocation, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct CompiledOutputBinding { + output: OutputSlotId, + paint_id: PaintId, + paint: CompiledPaintSlotV1, +} + +struct CompiledObservationGroupV1 { + id: ObservationGroupId, + schema: CanonicalObservationSchemaV1, +} + +struct ProgramEpochV1 +where + Evaluation: PointEvaluatorV1, + PointInvocation: Copy, +{ + evaluator: Evaluation, + graph: CompiledAppearanceGraph, + binding_template: AdmittedAppearanceBindings, + observation_group: CompiledObservationGroupV1, + constraints: Box<[CompiledPointConstraint>]>, + outputs: Box<[CompiledOutputBinding]>, +} + +/// Fully validated immutable Program, not yet attached to runtime. +pub struct CompiledProgram +where + Evaluation: PointEvaluatorV1, + PointInvocation: Copy, +{ + epoch: Rc>, +} + +impl CompiledProgram +where + Evaluation: PointEvaluatorV1, + PointInvocation: Copy, +{ + pub fn observation_group_id(&self) -> ObservationGroupId { + self.epoch.observation_group.id + } + + pub fn surface_input_ports(&self) -> &[SurfaceInputPortId] { + self.epoch.observation_group.schema.as_slice() + } + + pub fn constraint_ids(&self) -> impl ExactSizeIterator + '_ { + self.epoch + .constraints + .iter() + .map(|constraint| constraint.id) + } + + pub fn outputs(&self) -> impl ExactSizeIterator + '_ { + self.epoch + .outputs + .iter() + .map(|output| (output.output, output.paint_id)) + } + + /// Create one independent stream-affine Session from the immutable + /// compiled epoch. The graph/evaluator/schema stay shared by strong + /// ownership; mutable bindings and workspace belong only to this Session. + pub(crate) fn instantiate( + &self, + stream: ObservationStreamId, + ) -> Result>, ProgramSessionInstantiateError> { + let bindings = self + .epoch + .binding_template + .try_clone_v1() + .map_err(map_session_instantiate_error)?; + let workspace = self + .epoch + .graph + .new_workspace() + .map_err(map_session_instantiate_error)?; + Ok(Session::new( + stream, + ProgramSessionPlan { + epoch: Rc::clone(&self.epoch), + bindings, + workspace, + }, + )) + } +} + +/// Failure while preparing mutable storage for one independent Session. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProgramSessionInstantiateError { + ResourceExhausted, + InternalInvariant, +} + +fn map_session_instantiate_error(error: BindingError) -> ProgramSessionInstantiateError { + match error { + BindingError::ResourceExhausted => ProgramSessionInstantiateError::ResourceExhausted, + _ => ProgramSessionInstantiateError::InternalInvariant, + } +} + +/// One evaluator classification retained in the complete Program report. +pub enum ProgramConstraintResultV1 +where + Evaluation: PointEvaluatorV1, +{ + Pass(VisiblePointPassEvidence), + Violation(VisiblePointViolationEvidence), +} + +impl ProgramConstraintResultV1 +where + Evaluation: PointEvaluatorV1, +{ + pub const fn is_violation(&self) -> bool { + matches!(self, Self::Violation(_)) + } +} + +/// One canonical `physical case × constraint` report cell. +pub struct ProgramConstraintCellV1 +where + Evaluation: PointEvaluatorV1, +{ + case_index: usize, + constraint: ConstraintId, + target: OccurrenceId, + mode: CompiledConstraintModeV1, + result: ProgramConstraintResultV1, +} + +impl ProgramConstraintCellV1 +where + Evaluation: PointEvaluatorV1, +{ + pub const fn case_index(&self) -> usize { + self.case_index + } + + pub const fn constraint(&self) -> ConstraintId { + self.constraint + } + + pub const fn target(&self) -> OccurrenceId { + self.target + } + + pub const fn is_hard(&self) -> bool { + matches!(self.mode, CompiledConstraintModeV1::Hard) + } + + pub const fn result(&self) -> &ProgramConstraintResultV1 { + &self.result + } +} + +/// Complete revision-bound assessment in case-major, constraint-ID order. +pub struct ProgramReportV1 +where + Evaluation: PointEvaluatorV1, +{ + observation: RevisionBoundObservationV1, + cells: Vec>, +} + +impl ProgramReportV1 +where + Evaluation: PointEvaluatorV1, +{ + pub const fn observation(&self) -> &RevisionBoundObservationV1 { + &self.observation + } + + pub fn cells(&self) -> &[ProgramConstraintCellV1] { + &self.cells + } +} + +/// One emitted Program Paint routed to an opaque output slot. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProgramOutputV1 { + output: OutputSlotId, + paint: EncodedPointPaintV1, +} + +impl ProgramOutputV1 { + pub const fn output(self) -> OutputSlotId { + self.output + } + + pub const fn paint(self) -> EncodedPointPaintV1 { + self.paint + } +} + +/// All hard cells passed over the complete admitted physical support. +pub struct ProgramVerifiedV1 +where + Evaluation: PointEvaluatorV1, +{ + report: ProgramReportV1, + outputs: Vec, +} + +impl session_private::EvidenceSealed for ProgramVerifiedV1 where + Evaluation: PointEvaluatorV1 +{ +} + +impl SessionEvidenceV1 for ProgramVerifiedV1 +where + Evaluation: PointEvaluatorV1, +{ + fn observation(&self) -> &RevisionBoundObservationV1 { + self.report().observation() + } +} + +impl ProgramVerifiedV1 +where + Evaluation: PointEvaluatorV1, +{ + pub const fn report(&self) -> &ProgramReportV1 { + &self.report + } + + pub fn outputs(&self) -> &[ProgramOutputV1] { + &self.outputs + } +} + +/// Complete report containing at least one hard violation. Outputs are absent +/// by construction and therefore cannot be mistaken for committed Paints. +pub struct ProgramViolationV1 +where + Evaluation: PointEvaluatorV1, +{ + report: ProgramReportV1, +} + +impl session_private::EvidenceSealed for ProgramViolationV1 where + Evaluation: PointEvaluatorV1 +{ +} + +impl SessionEvidenceV1 for ProgramViolationV1 +where + Evaluation: PointEvaluatorV1, +{ + fn observation(&self) -> &RevisionBoundObservationV1 { + self.report().observation() + } +} + +impl ProgramViolationV1 +where + Evaluation: PointEvaluatorV1, +{ + pub const fn report(&self) -> &ProgramReportV1 { + &self.report + } +} + +/// Program execution failure before Session commit. +#[derive(Debug, PartialEq, Eq)] +pub enum ProgramSessionEvaluationError { + ObservationSchemaMismatch(ObservationSchemaMismatchV1), + ResourceExhausted, + Evaluator { + case_index: usize, + constraint: ConstraintId, + source: EvaluationError, + }, + OutputVariesAcrossCases { + output: OutputSlotId, + first_case: usize, + actual_case: usize, + }, + InternalInvariant, +} + +type ProgramEvaluatorError = >::Error; + +type ProgramSessionEvaluationResult = Result< + SessionDecision, ProgramViolationV1>, + ProgramSessionEvaluationError>, +>; + +/// Per-Session mutable execution state backed by one strong immutable epoch. +pub struct ProgramSessionPlan +where + Evaluation: PointEvaluatorV1, + PointInvocation: Copy, +{ + epoch: Rc>, + bindings: AdmittedAppearanceBindings, + workspace: AppearanceWorkspace, +} + +impl session_private::PlanSealed for ProgramSessionPlan +where + Evaluation: PointEvaluatorV1, + PointInvocation: Copy, +{ +} + +impl SessionPlanV1 for ProgramSessionPlan +where + Evaluation: PointEvaluatorV1, + PointInvocation: Copy, +{ + type Verified = ProgramVerifiedV1; + type Violation = ProgramViolationV1; + type Error = ProgramSessionEvaluationError>; + + fn observation_schema(&self) -> &CanonicalObservationSchemaV1 { + &self.epoch.observation_group.schema + } + + fn evaluate( + &mut self, + observation: RevisionBoundObservationV1, + _permit: SessionObservationBindingPermitV1, + ) -> Result, Self::Error> { + evaluate_program_session(self, observation) + } +} + +fn evaluate_program_session( + plan: &mut ProgramSessionPlan, + observation: RevisionBoundObservationV1, +) -> ProgramSessionEvaluationResult +where + Evaluation: PointEvaluatorV1, + PointInvocation: Copy, +{ + let epoch = &plan.epoch; + let schema = &epoch.observation_group.schema; + if !observation.shares_schema_backing_with(schema) { + observation + .validate_surface_schema(schema.as_slice()) + .map_err(ProgramSessionEvaluationError::ObservationSchemaMismatch)?; + return Err(ProgramSessionEvaluationError::InternalInvariant); + } + + let case_count = observation.physical_case_count(); + let cell_count = case_count + .checked_mul(epoch.constraints.len()) + .ok_or(ProgramSessionEvaluationError::ResourceExhausted)?; + let mut cells = Vec::new(); + cells + .try_reserve_exact(cell_count) + .map_err(|_| ProgramSessionEvaluationError::ResourceExhausted)?; + let mut outputs = Vec::new(); + outputs + .try_reserve_exact(epoch.outputs.len()) + .map_err(|_| ProgramSessionEvaluationError::ResourceExhausted)?; + + let mut has_hard_violation = false; + let mut output_mismatch = None; + for case_index in 0..case_count { + let values = observation + .physical_values(case_index) + .ok_or(ProgramSessionEvaluationError::InternalInvariant)?; + if values.len() != schema.as_slice().len() { + let binding_index = values.len().min(schema.as_slice().len()); + return Err(ProgramSessionEvaluationError::ObservationSchemaMismatch( + ObservationSchemaMismatchV1::new( + case_index, + binding_index, + schema.as_slice().get(binding_index).copied(), + None, + ), + )); + } + plan.bindings + .overwrite_surface_inputs_canonical(schema.as_slice().iter().copied(), |index| { + values[index] + }) + .map_err(map_program_execution_binding_error)?; + let evaluation = epoch + .graph + .evaluate_admitted_into(&plan.bindings, &mut plan.workspace) + .map_err(map_program_execution_binding_error)?; + + for constraint in epoch.constraints.iter() { + let source = evaluation + .occurrence_at(constraint.target) + .ok_or(ProgramSessionEvaluationError::InternalInvariant)?; + let decision = + assess_visible_point_hard(source, &epoch.evaluator, constraint.invocation) + .map_err(|source| ProgramSessionEvaluationError::Evaluator { + case_index, + constraint: constraint.id, + source, + })?; + let result = match decision { + HardDecision::Pass(evidence) => ProgramConstraintResultV1::Pass(evidence), + HardDecision::Violation(evidence) => { + if matches!(constraint.mode, CompiledConstraintModeV1::Hard) { + has_hard_violation = true; + } + ProgramConstraintResultV1::Violation(evidence) + } + }; + cells.push(ProgramConstraintCellV1 { + case_index, + constraint: constraint.id, + target: constraint.target_id, + mode: constraint.mode, + result, + }); + } + + for (output_index, output) in epoch.outputs.iter().enumerate() { + let paint = evaluation + .paint_at(output.paint) + .copied() + .ok_or(ProgramSessionEvaluationError::InternalInvariant)?; + if paint.id() != output.paint_id { + return Err(ProgramSessionEvaluationError::InternalInvariant); + } + let routed = ProgramOutputV1 { + output: output.output, + paint, + }; + if case_index == 0 { + outputs.push(routed); + } else if outputs.get(output_index).copied() != Some(routed) + && output_mismatch.is_none() + { + output_mismatch = Some(ProgramSessionEvaluationError::OutputVariesAcrossCases { + output: output.output, + first_case: 0, + actual_case: case_index, + }); + } + } + } + + let report = ProgramReportV1 { observation, cells }; + if let Some(error) = output_mismatch { + Err(error) + } else if has_hard_violation { + Ok(SessionDecision::Violation(ProgramViolationV1 { report })) + } else { + Ok(SessionDecision::Verified(ProgramVerifiedV1 { + report, + outputs, + })) + } +} + +fn map_program_execution_binding_error( + error: BindingError, +) -> ProgramSessionEvaluationError { + match error { + BindingError::ResourceExhausted => ProgramSessionEvaluationError::ResourceExhausted, + _ => ProgramSessionEvaluationError::InternalInvariant, + } +} + +fn prepare_program( + program: Program, +) -> Result, ProgramCompileError> +where + Evaluation: PointEvaluatorV1, + PointInvocation: Copy, +{ + if program.observation_group.surface_input_ports.is_empty() { + return Err(ProgramCompileError::EmptyObservationGroup { + group: program.observation_group.id, + }); + } + if program.occurrences.is_empty() { + return Err(ProgramCompileError::EmptyOccurrenceSet); + } + if program.constraints.is_empty() { + return Err(ProgramCompileError::EmptyConstraintSet); + } + if program.outputs.is_empty() { + return Err(ProgramCompileError::EmptyOutputSet); + } + check_render_node_count(program.surfaces.len(), program.occurrences.len())?; + program + .constraints + .checked_len() + .ok_or(ProgramCompileError::ResourceExhausted)?; + + let graph = lower_graph(&program).compile().map_err(map_compile_error)?; + let binding_template = graph + .admit_bindings(&lower_bindings(&program)) + .map_err(map_binding_compile_error)?; + + let mut surface_input_ports = Vec::new(); + surface_input_ports + .try_reserve_exact(program.observation_group.surface_input_ports.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + surface_input_ports.extend_from_slice(&program.observation_group.surface_input_ports); + surface_input_ports.sort_unstable(); + if !canonical_surface_input_port_sequence_matches( + graph.surface_input_ports(), + &surface_input_ports, + ) { + return Err(ProgramCompileError::InternalInvariant); + } + let observation_schema = canonicalize_observation_schema(surface_input_ports) + .map_err(map_observation_schema_compile_error)?; + + let constraints = compile_constraints::(&graph, program.constraints)?; + let outputs = compile_outputs(&graph, program.outputs)?; + Ok(ProgramEpochV1 { + evaluator: program.evaluator, + graph, + binding_template, + observation_group: CompiledObservationGroupV1 { + id: program.observation_group.id, + schema: observation_schema, + }, + constraints, + outputs, + }) +} + +fn map_observation_schema_compile_error(error: ObservationError) -> ProgramCompileError { + match error { + ObservationError::ResourceExhausted => ProgramCompileError::ResourceExhausted, + _ => ProgramCompileError::InternalInvariant, + } +} + +struct LoweredConstraint { + id: ConstraintId, + target: OccurrenceId, + mode: CompiledConstraintModeV1, + invocation: Invocation, +} + +fn compile_constraints( + graph: &CompiledAppearanceGraph, + authored: ConstraintSet>, +) -> Result>]>, ProgramCompileError> +where + Evaluation: PointEvaluatorV1, + PointInvocation: Copy, +{ + let total = authored + .hard + .len() + .checked_add(authored.report_only.len()) + .ok_or(ProgramCompileError::ResourceExhausted)?; + let mut lowered = Vec::new(); + lowered + .try_reserve_exact(total) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + lowered.extend( + authored + .hard + .into_iter() + .map(|constraint| LoweredConstraint { + id: constraint.id, + target: constraint.target, + mode: CompiledConstraintModeV1::Hard, + invocation: constraint.invocation, + }), + ); + lowered.extend( + authored + .report_only + .into_iter() + .map(|constraint| LoweredConstraint { + id: constraint.id, + target: constraint.target, + mode: CompiledConstraintModeV1::ReportOnly, + invocation: constraint.invocation, + }), + ); + lowered.sort_unstable_by_key(|constraint| constraint.id); + if let Some(duplicate) = lowered + .windows(2) + .find(|pair| pair[0].id == pair[1].id) + .map(|pair| pair[0].id) + { + return Err(ProgramCompileError::DuplicateConstraint { + constraint: duplicate, + }); + } + for constraint in &lowered { + if graph.bind_occurrence(constraint.target).is_none() { + return Err(ProgramCompileError::MissingConstraintOccurrence { + constraint: constraint.id, + occurrence: constraint.target, + }); + } + } + + let mut compiled = Vec::new(); + compiled + .try_reserve_exact(total) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + for constraint in lowered { + let target = graph + .bind_occurrence(constraint.target) + .ok_or(ProgramCompileError::InternalInvariant)?; + compiled.push(CompiledPointConstraint { + id: constraint.id, + target_id: constraint.target, + target, + mode: constraint.mode, + invocation: constraint.invocation, + }); + } + Ok(compiled.into_boxed_slice()) +} + +fn compile_outputs( + graph: &CompiledAppearanceGraph, + authored: Vec, +) -> Result, ProgramCompileError> { + let len = authored.len(); + let mut authored = authored; + authored.sort_unstable_by_key(|output| output.output); + if let Some(duplicate) = authored + .windows(2) + .find(|pair| pair[0].output == pair[1].output) + .map(|pair| pair[0].output) + { + return Err(ProgramCompileError::DuplicateOutputSlot { output: duplicate }); + } + for output in &authored { + if graph.bind_paint(output.paint).is_none() { + return Err(ProgramCompileError::MissingOutputPaint { + output: output.output, + paint: output.paint, + }); + } + } + + let mut compiled = Vec::new(); + compiled + .try_reserve_exact(len) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + for output in authored { + let paint = graph + .bind_paint(output.paint) + .ok_or(ProgramCompileError::InternalInvariant)?; + compiled.push(CompiledOutputBinding { + output: output.output, + paint_id: output.paint, + paint, + }); + } + Ok(compiled.into_boxed_slice()) +} + +pub(crate) fn check_render_node_count( + surface_count: usize, + occurrence_count: usize, +) -> Result<(), ProgramCompileError> { + surface_count + .checked_add(occurrence_count) + .ok_or(ProgramCompileError::ResourceExhausted) + .map(|_| ()) +} + +pub(crate) fn canonical_surface_input_port_sequence_matches( + actual: impl IntoIterator, + expected: &[SurfaceInputPortId], +) -> bool { + actual.into_iter().eq(expected.iter().copied()) +} + +fn lower_graph(program: &Program) -> AppearanceGraphSpec +where + Evaluation: PointEvaluatorV1, + PointInvocation: Copy, +{ + AppearanceGraphSpec::new( + program.colors.iter().map(|input| input.id).collect(), + program.observation_group.surface_input_ports.clone(), + program.opacities.iter().map(|input| input.id).collect(), + program + .paints + .iter() + .map(|paint| match *paint { + Paint::Solid { id, color } => PaintSpec::Solid { id, color }, + Paint::Opacity { + id, + source, + opacity, + } => PaintSpec::Opacity { + id, + source, + opacity, + }, + }) + .collect(), + program + .surfaces + .iter() + .map(|surface| match *surface { + Surface::Input { id, input } => SurfaceSpec::Input { id, port: input }, + Surface::FromOccurrence { id, occurrence } => { + SurfaceSpec::FromOccurrence { id, occurrence } + } + }) + .collect(), + program + .occurrences + .iter() + .map(|occurrence| OccurrenceSpec { + id: occurrence.id, + subject: occurrence.subject, + against: occurrence.against, + profile: match occurrence.composition { + CompositionProfile::EncodedSrgb8SourceOverV1 => { + CompositionProfileV1::EncodedSrgb8SourceOverV1 + } + }, + }) + .collect(), + ) +} + +fn lower_bindings(program: &Program) -> AppearanceBindings +where + Evaluation: PointEvaluatorV1, + PointInvocation: Copy, +{ + AppearanceBindings::new( + program + .colors + .iter() + .map(|input| (input.id, input.value)) + .collect(), + program + .observation_group + .surface_input_ports + .iter() + .map(|input| (*input, Srgb8::new([0; 3]))) + .collect(), + program + .opacities + .iter() + .map(|input| (input.id, input.value)) + .collect(), + ) +} + +fn map_compile_error(error: CompileError) -> ProgramCompileError { + match error { + CompileError::DuplicateColorInput { input } => { + ProgramCompileError::DuplicateColorInput { input } + } + CompileError::DuplicateOpacityInput { input } => { + ProgramCompileError::DuplicateOpacityInput { input } + } + CompileError::DuplicateSurfaceInputPort { input } => { + ProgramCompileError::DuplicateSurfaceInputPort { input } + } + CompileError::DuplicatePaint { paint } => ProgramCompileError::DuplicatePaint { paint }, + CompileError::DuplicateSurface { surface } => { + ProgramCompileError::DuplicateSurface { surface } + } + CompileError::DuplicateOccurrence { occurrence } => { + ProgramCompileError::DuplicateOccurrence { occurrence } + } + CompileError::MissingPaintColorInput { paint, input } => { + ProgramCompileError::MissingPaintColorInput { paint, input } + } + CompileError::MissingPaintSource { paint, source } => { + ProgramCompileError::MissingPaintSource { paint, source } + } + CompileError::MissingPaintOpacityInput { paint, input } => { + ProgramCompileError::MissingPaintOpacityInput { paint, input } + } + CompileError::MissingSurfaceInputPort { surface, input } => { + ProgramCompileError::MissingSurfaceInputPort { surface, input } + } + CompileError::MissingSurfaceOccurrence { + surface, + occurrence, + } => ProgramCompileError::MissingSurfaceOccurrence { + surface, + occurrence, + }, + CompileError::MissingOccurrencePaint { occurrence, paint } => { + ProgramCompileError::MissingOccurrencePaint { occurrence, paint } + } + CompileError::MissingOccurrenceBackdrop { + occurrence, + surface, + } => ProgramCompileError::MissingOccurrenceBackdrop { + occurrence, + surface, + }, + CompileError::PaintCycle { paints } => ProgramCompileError::PaintCycle { paints }, + CompileError::RenderCycle { + surfaces, + occurrences, + } => ProgramCompileError::RenderCycle { + surfaces, + occurrences, + }, + } +} + +fn map_binding_compile_error(error: BindingError) -> ProgramCompileError { + match error { + BindingError::OpacityOutOfDomain { input, .. } => { + ProgramCompileError::OpacityOutOfDomain { input } + } + BindingError::ResourceExhausted => ProgramCompileError::ResourceExhausted, + _ => ProgramCompileError::InternalInvariant, + } +} diff --git a/crates/labcolors-core/src/program_session_tests.rs b/crates/labcolors-core/src/program_session_tests.rs new file mode 100644 index 00000000..c80befe3 --- /dev/null +++ b/crates/labcolors-core/src/program_session_tests.rs @@ -0,0 +1,784 @@ +use crate::Srgb8; +use crate::appearance::{ + ColorInputId, OccurrenceId, OpacityInputId, PaintId, SurfaceId, SurfaceInputPortId, +}; +use crate::constraints::{ExactSrgb8IdentityV1, PointEvaluatorV1, PointInvocation}; +use crate::observation::{ + ObservationGroupId, ObservationHeadViewV1, ObservationPayloadInput, ObservationStreamId, + ObservationUpdateInput, ObservedScenarioSetInput, Revision, ScenarioId, ScenarioInput, + SurfaceInputBinding, +}; +use crate::program_session::{ + ColorInput, CompositionProfile, ConstraintId, ConstraintInvocation, ConstraintSet, + ObservationGroup, Occurrence, OpacityInput, OutputBinding, OutputSlotId, Paint, Program, + ProgramCompileError, Surface, canonical_surface_input_port_sequence_matches, + check_render_node_count, +}; +use crate::session::SessionState; + +const COLOR: ColorInputId = ColorInputId::new(1); +const SURFACE_PORT: SurfaceInputPortId = SurfaceInputPortId::new(2); +const OPACITY: OpacityInputId = OpacityInputId::new(3); +const SOLID: PaintId = PaintId::new(10); +const TRANSLUCENT: PaintId = PaintId::new(11); +const BACKDROP: SurfaceId = SurfaceId::new(20); +const VISIBLE_SURFACE: SurfaceId = SurfaceId::new(21); +const OCCURRENCE: OccurrenceId = OccurrenceId::new(30); +const OUTPUT: OutputSlotId = OutputSlotId::new(40); +const REQUIRED: ConstraintId = ConstraintId::new(50); +const GROUP: ObservationGroupId = ObservationGroupId::new(60); +const STREAM_A: ObservationStreamId = ObservationStreamId::new(70); +const STREAM_B: ObservationStreamId = ObservationStreamId::new(71); + +fn observation_group(surface_input_ports: Vec) -> ObservationGroup { + ObservationGroup::new(GROUP, surface_input_ports) +} + +fn base_program( + opacity: f64, + against: SurfaceId, + constraints: ConstraintSet>, + outputs: Vec, + evaluator: Evaluation, +) -> Program +where + Evaluation: PointEvaluatorV1, + PointInvocation: Copy, +{ + base_program_in_group(GROUP, opacity, against, constraints, outputs, evaluator) +} + +fn base_program_in_group( + group: ObservationGroupId, + opacity: f64, + against: SurfaceId, + constraints: ConstraintSet>, + outputs: Vec, + evaluator: Evaluation, +) -> Program +where + Evaluation: PointEvaluatorV1, + PointInvocation: Copy, +{ + Program::new( + vec![ColorInput::new(COLOR, Srgb8::new([0; 3]))], + ObservationGroup::new(group, vec![SURFACE_PORT]), + vec![OpacityInput::new(OPACITY, opacity)], + vec![ + Paint::Solid { + id: SOLID, + color: COLOR, + }, + Paint::Opacity { + id: TRANSLUCENT, + source: SOLID, + opacity: OPACITY, + }, + ], + vec![ + Surface::Input { + id: BACKDROP, + input: SURFACE_PORT, + }, + Surface::FromOccurrence { + id: VISIBLE_SURFACE, + occurrence: OCCURRENCE, + }, + ], + vec![Occurrence::new( + OCCURRENCE, + TRANSLUCENT, + against, + CompositionProfile::EncodedSrgb8SourceOverV1, + )], + constraints, + outputs, + evaluator, + ) +} + +fn compile_error(program: Program) -> ProgramCompileError +where + Evaluation: PointEvaluatorV1, + PointInvocation: Copy, +{ + match program.compile() { + Ok(_) => panic!("invalid declaration compiled"), + Err(error) => error, + } +} + +fn observed_update( + stream: ObservationStreamId, + revision: u64, + scenarios: &[(u32, [u8; 3])], +) -> ObservationUpdateInput { + ObservationUpdateInput { + stream, + revision: Revision::new(revision), + payload: ObservationPayloadInput::Scenarios(ObservedScenarioSetInput { + scenarios: scenarios + .iter() + .map(|(scenario, backdrop)| ScenarioInput { + id: ScenarioId::new(*scenario), + bindings: vec![SurfaceInputBinding::new( + SURFACE_PORT, + Srgb8::new(*backdrop), + )], + }) + .collect(), + }), + } +} + +fn exact_compiled( + constraints: ConstraintSet, +) -> crate::program_session::CompiledProgram { + base_program( + 0.5, + BACKDROP, + constraints, + vec![OutputBinding::new(OUTPUT, TRANSLUCENT)], + ExactSrgb8IdentityV1, + ) + .compile() + .unwrap() +} + +#[test] +fn authored_modes_are_marker_typed_and_values_preserve_exact_ids() { + let hard = ConstraintInvocation::hard(REQUIRED, OCCURRENCE, Srgb8::new([0x80; 3])); + let report = + ConstraintInvocation::report_only(ConstraintId::new(51), OCCURRENCE, Srgb8::new([0x81; 3])); + let set = ConstraintSet::new(vec![hard], vec![report]); + assert_eq!(set.hard()[0].id(), REQUIRED); + assert_eq!(set.hard()[0].target(), OCCURRENCE); + assert_eq!(*set.hard()[0].invocation(), Srgb8::new([0x80; 3])); + assert_eq!(set.report_only()[0].id(), ConstraintId::new(51)); + + let output = OutputBinding::new(OUTPUT, TRANSLUCENT); + assert_eq!(output.output(), OUTPUT); + assert_eq!(output.paint(), TRANSLUCENT); + assert_eq!(ConstraintId::new(7).value(), 7); + assert_eq!(OutputSlotId::new(8).value(), 8); + + let color = ColorInput::new(COLOR, Srgb8::new([1, 2, 3])); + assert_eq!(color.id(), COLOR); + assert_eq!(color.value(), Srgb8::new([1, 2, 3])); + let opacity = OpacityInput::new(OPACITY, 0.375); + assert_eq!(opacity.id(), OPACITY); + assert_eq!(opacity.value(), 0.375); + + let occurrence = Occurrence::new( + OCCURRENCE, + TRANSLUCENT, + BACKDROP, + CompositionProfile::EncodedSrgb8SourceOverV1, + ); + assert_eq!(occurrence.id(), OCCURRENCE); + assert_eq!(occurrence.subject(), TRANSLUCENT); + assert_eq!(occurrence.against(), BACKDROP); + assert_eq!( + occurrence.composition(), + CompositionProfile::EncodedSrgb8SourceOverV1 + ); + + let group = observation_group(vec![SURFACE_PORT]); + assert_eq!(group.id(), GROUP); + assert_eq!(group.surface_input_ports(), &[SURFACE_PORT]); +} + +#[test] +fn empty_domains_have_stable_precedence() { + let empty_surface = Program::new( + vec![ColorInput::new(COLOR, Srgb8::new([0; 3]))], + observation_group(vec![]), + vec![], + vec![Paint::Solid { + id: SOLID, + color: COLOR, + }], + vec![Surface::Input { + id: BACKDROP, + input: SURFACE_PORT, + }], + vec![Occurrence::new( + OCCURRENCE, + SOLID, + BACKDROP, + CompositionProfile::EncodedSrgb8SourceOverV1, + )], + ConstraintSet::new( + vec![ConstraintInvocation::hard( + REQUIRED, + OCCURRENCE, + Srgb8::new([0; 3]), + )], + vec![], + ), + vec![OutputBinding::new(OUTPUT, SOLID)], + ExactSrgb8IdentityV1, + ); + assert_eq!( + compile_error(empty_surface), + ProgramCompileError::EmptyObservationGroup { group: GROUP } + ); + + let empty_occurrence = Program::new( + vec![ColorInput::new(COLOR, Srgb8::new([0; 3]))], + observation_group(vec![SURFACE_PORT]), + vec![], + vec![Paint::Solid { + id: SOLID, + color: COLOR, + }], + vec![Surface::Input { + id: BACKDROP, + input: SURFACE_PORT, + }], + vec![], + ConstraintSet::new( + vec![ConstraintInvocation::hard( + REQUIRED, + OCCURRENCE, + Srgb8::new([0; 3]), + )], + vec![], + ), + vec![OutputBinding::new(OUTPUT, SOLID)], + ExactSrgb8IdentityV1, + ); + assert_eq!( + compile_error(empty_occurrence), + ProgramCompileError::EmptyOccurrenceSet + ); + + let empty_constraints = base_program( + 0.5, + BACKDROP, + ConstraintSet::::new(vec![], vec![]), + vec![], + ExactSrgb8IdentityV1, + ); + assert_eq!( + compile_error(empty_constraints), + ProgramCompileError::EmptyConstraintSet + ); + + let empty_outputs = base_program( + 0.5, + BACKDROP, + ConstraintSet::new( + vec![ConstraintInvocation::hard( + REQUIRED, + OCCURRENCE, + Srgb8::new([0x80; 3]), + )], + vec![], + ), + vec![], + ExactSrgb8IdentityV1, + ); + assert_eq!( + compile_error(empty_outputs), + ProgramCompileError::EmptyOutputSet + ); +} + +#[test] +fn physical_errors_precede_constraint_and_output_errors() { + let missing_surface = SurfaceId::new(999); + let duplicate = ConstraintId::new(77); + let program = base_program( + 0.5, + missing_surface, + ConstraintSet::new( + vec![ConstraintInvocation::hard( + duplicate, + OccurrenceId::new(998), + Srgb8::new([0; 3]), + )], + vec![ConstraintInvocation::report_only( + duplicate, + OccurrenceId::new(997), + Srgb8::new([0; 3]), + )], + ), + vec![ + OutputBinding::new(OUTPUT, PaintId::new(996)), + OutputBinding::new(OUTPUT, PaintId::new(995)), + ], + ExactSrgb8IdentityV1, + ); + assert_eq!( + compile_error(program), + ProgramCompileError::MissingOccurrenceBackdrop { + occurrence: OCCURRENCE, + surface: missing_surface, + } + ); +} + +#[test] +fn constraint_and_output_error_precedence_is_canonical() { + let duplicate = ConstraintId::new(77); + let missing_occurrence = OccurrenceId::new(999); + let duplicate_constraints = base_program( + 0.5, + BACKDROP, + ConstraintSet::new( + vec![ConstraintInvocation::hard( + duplicate, + missing_occurrence, + Srgb8::new([0; 3]), + )], + vec![ConstraintInvocation::report_only( + duplicate, + OCCURRENCE, + Srgb8::new([0; 3]), + )], + ), + vec![ + OutputBinding::new(OUTPUT, PaintId::new(998)), + OutputBinding::new(OUTPUT, TRANSLUCENT), + ], + ExactSrgb8IdentityV1, + ); + assert_eq!( + compile_error(duplicate_constraints), + ProgramCompileError::DuplicateConstraint { + constraint: duplicate, + } + ); + + let missing_constraint = base_program( + 0.5, + BACKDROP, + ConstraintSet::new( + vec![ConstraintInvocation::hard( + REQUIRED, + missing_occurrence, + Srgb8::new([0; 3]), + )], + vec![], + ), + vec![ + OutputBinding::new(OUTPUT, TRANSLUCENT), + OutputBinding::new(OUTPUT, PaintId::new(998)), + ], + ExactSrgb8IdentityV1, + ); + assert_eq!( + compile_error(missing_constraint), + ProgramCompileError::MissingConstraintOccurrence { + constraint: REQUIRED, + occurrence: missing_occurrence, + } + ); + + let duplicate_output = base_program( + 0.5, + BACKDROP, + ConstraintSet::new( + vec![ConstraintInvocation::hard( + REQUIRED, + OCCURRENCE, + Srgb8::new([0x80; 3]), + )], + vec![], + ), + vec![ + OutputBinding::new(OUTPUT, PaintId::new(998)), + OutputBinding::new(OUTPUT, TRANSLUCENT), + ], + ExactSrgb8IdentityV1, + ); + assert_eq!( + compile_error(duplicate_output), + ProgramCompileError::DuplicateOutputSlot { output: OUTPUT } + ); + + let missing_paint = PaintId::new(998); + let missing_output = base_program( + 0.5, + BACKDROP, + ConstraintSet::new( + vec![ConstraintInvocation::hard( + REQUIRED, + OCCURRENCE, + Srgb8::new([0x80; 3]), + )], + vec![], + ), + vec![OutputBinding::new(OUTPUT, missing_paint)], + ExactSrgb8IdentityV1, + ); + assert_eq!( + compile_error(missing_output), + ProgramCompileError::MissingOutputPaint { + output: OUTPUT, + paint: missing_paint, + } + ); +} + +#[test] +fn compile_canonicalizes_constraints_and_outputs_independent_of_mode_lists() { + let low = ConstraintId::new(1); + let high = ConstraintId::new(9); + let output_low = OutputSlotId::new(2); + let output_high = OutputSlotId::new(8); + let compiled = base_program( + 0.5, + BACKDROP, + ConstraintSet::new( + vec![ConstraintInvocation::hard( + high, + OCCURRENCE, + Srgb8::new([0x80; 3]), + )], + vec![ConstraintInvocation::report_only( + low, + OCCURRENCE, + Srgb8::new([0x80; 3]), + )], + ), + vec![ + OutputBinding::new(output_high, TRANSLUCENT), + OutputBinding::new(output_low, TRANSLUCENT), + ], + ExactSrgb8IdentityV1, + ) + .compile() + .unwrap(); + assert_eq!(compiled.observation_group_id(), GROUP); + assert_eq!(compiled.surface_input_ports(), &[SURFACE_PORT]); + assert_eq!( + compiled.constraint_ids().collect::>(), + vec![low, high] + ); + assert_eq!( + compiled.outputs().collect::>(), + vec![(output_low, TRANSLUCENT), (output_high, TRANSLUCENT)] + ); +} + +#[test] +fn canonical_helpers_and_checked_cardinality_fail_closed() { + assert_eq!(check_render_node_count(usize::MAX - 1, 1), Ok(())); + assert_eq!( + check_render_node_count(usize::MAX, 1), + Err(ProgramCompileError::ResourceExhausted) + ); + let canonical = [SurfaceInputPortId::new(1), SurfaceInputPortId::new(2)]; + assert!(canonical_surface_input_port_sequence_matches( + [SurfaceInputPortId::new(1), SurfaceInputPortId::new(2)], + &canonical, + )); + assert!(!canonical_surface_input_port_sequence_matches( + [SurfaceInputPortId::new(2), SurfaceInputPortId::new(1)], + &canonical, + )); + assert!(!canonical_surface_input_port_sequence_matches( + [SurfaceInputPortId::new(1)], + &canonical, + )); +} + +#[test] +fn independently_instantiated_streams_survive_the_compiled_handle() { + let compiled = exact_compiled(ConstraintSet::new( + vec![ConstraintInvocation::hard( + REQUIRED, + OCCURRENCE, + Srgb8::new([0x80; 3]), + )], + vec![], + )); + let mut first = compiled.instantiate(STREAM_A).unwrap(); + let mut second = compiled.instantiate(STREAM_B).unwrap(); + drop(compiled); + + let first_state = first + .update(observed_update(STREAM_A, 1, &[(1, [0xFF; 3])])) + .unwrap(); + let SessionState::Ready { current: first } = first_state else { + panic!("first independent stream must verify"); + }; + assert_eq!(first.outputs().len(), 1); + + let second_state = second + .update(observed_update(STREAM_B, 9, &[(2, [0xFF; 3])])) + .unwrap(); + let SessionState::Ready { current: second } = second_state else { + panic!("second independent stream must verify after compiled handle drop"); + }; + assert_eq!(second.outputs().len(), 1); + assert_eq!(first.report().observation().revision(), Revision::new(1)); + assert_eq!(second.report().observation().revision(), Revision::new(9)); +} + +#[test] +fn multi_case_hard_failure_retains_the_full_matrix_without_outputs() { + let low = ConstraintId::new(1); + let high = ConstraintId::new(2); + let compiled = exact_compiled(ConstraintSet::new( + vec![ + ConstraintInvocation::hard(high, OCCURRENCE, Srgb8::new([0x00; 3])), + ConstraintInvocation::hard(low, OCCURRENCE, Srgb8::new([0x80; 3])), + ], + vec![], + )); + let mut session = compiled.instantiate(STREAM_A).unwrap(); + let state = session + .update(observed_update( + STREAM_A, + 1, + &[(11, [0x00; 3]), (12, [0xFF; 3])], + )) + .unwrap(); + let SessionState::Failed { cause, previous } = state else { + panic!("each candidate target fails on one admitted physical case"); + }; + assert!(previous.is_none()); + let cells = cause.report().cells(); + assert_eq!( + cells.len(), + 4, + "two cases × two constraints must be complete" + ); + assert_eq!( + cells + .iter() + .map(|cell| (cell.case_index(), cell.constraint())) + .collect::>(), + vec![(0, low), (0, high), (1, low), (1, high)], + ); + assert!(cells.iter().all(|cell| cell.is_hard())); + assert!(cells.iter().all(|cell| cell.target() == OCCURRENCE)); + assert_eq!( + cells + .iter() + .filter(|cell| cell.result().is_violation()) + .count(), + 2, + ); + // `ProgramViolationV1` owns only the complete report; no output accessor or + // output storage exists on the failure type. +} + +#[test] +fn mixed_modes_retain_the_full_canonical_matrix_without_outputs_on_hard_failure() { + let diagnostic = ConstraintId::new(1); + let required = ConstraintId::new(2); + let compiled = exact_compiled(ConstraintSet::new( + vec![ConstraintInvocation::hard( + required, + OCCURRENCE, + Srgb8::new([0x00; 3]), + )], + vec![ConstraintInvocation::report_only( + diagnostic, + OCCURRENCE, + Srgb8::new([0x80; 3]), + )], + )); + let mut session = compiled.instantiate(STREAM_A).unwrap(); + let state = session + .update(observed_update( + STREAM_A, + 1, + &[(11, [0x00; 3]), (12, [0xFF; 3])], + )) + .unwrap(); + let SessionState::Failed { cause, previous } = state else { + panic!("the hard constraint must gate the otherwise complete mixed report"); + }; + assert!(previous.is_none()); + + let cells = cause.report().cells(); + assert_eq!(cells.len(), 4); + assert_eq!( + cells + .iter() + .map(|cell| (cell.case_index(), cell.constraint(), cell.is_hard())) + .collect::>(), + vec![ + (0, diagnostic, false), + (0, required, true), + (1, diagnostic, false), + (1, required, true), + ], + ); + assert_eq!( + cells + .iter() + .map(|cell| cell.result().is_violation()) + .collect::>(), + vec![true, false, false, true], + ); + assert!(cells.iter().all(|cell| cell.target() == OCCURRENCE)); + // `cause` is `ProgramViolationV1`: the failure surface exposes only this + // complete report, while Paint outputs exist only on `ProgramVerifiedV1`. +} + +#[test] +fn report_only_violations_do_not_block_program_scope_paint_outputs() { + let diagnostic = ConstraintId::new(7); + let compiled = exact_compiled(ConstraintSet::new( + vec![], + vec![ConstraintInvocation::report_only( + diagnostic, + OCCURRENCE, + Srgb8::new([0x7F; 3]), + )], + )); + let mut session = compiled.instantiate(STREAM_A).unwrap(); + let state = session + .update(observed_update( + STREAM_A, + 1, + &[(1, [0x00; 3]), (2, [0xFF; 3])], + )) + .unwrap(); + let SessionState::Ready { current } = state else { + panic!("report-only violations must not gate outputs"); + }; + assert_eq!(current.report().cells().len(), 2); + assert!( + current + .report() + .cells() + .iter() + .all(|cell| !cell.is_hard() && cell.result().is_violation()), + ); + let [output] = current.outputs() else { + panic!("one canonical output must be emitted"); + }; + assert_eq!(output.output(), OUTPUT); + assert_eq!(output.paint().id(), TRANSLUCENT); + assert_eq!(output.paint().source(), Srgb8::new([0; 3])); + assert_eq!(output.paint().opacity_bits(), 0.5_f64.to_bits()); +} + +#[test] +fn nested_surface_uses_the_lower_occurrence_before_assessing_the_upper() { + const LOWER_COLOR: ColorInputId = ColorInputId::new(101); + const UPPER_COLOR: ColorInputId = ColorInputId::new(102); + const HALF: OpacityInputId = OpacityInputId::new(103); + const LOWER_PAINT: PaintId = PaintId::new(110); + const UPPER_SOLID: PaintId = PaintId::new(111); + const UPPER_PAINT: PaintId = PaintId::new(112); + const ROOT: SurfaceId = SurfaceId::new(120); + const DERIVED: SurfaceId = SurfaceId::new(121); + const LOWER: OccurrenceId = OccurrenceId::new(130); + const UPPER: OccurrenceId = OccurrenceId::new(131); + const NESTED_OUTPUT: OutputSlotId = OutputSlotId::new(140); + + let program = Program::new( + vec![ + ColorInput::new(LOWER_COLOR, Srgb8::new([0x80; 3])), + ColorInput::new(UPPER_COLOR, Srgb8::new([0xFF; 3])), + ], + observation_group(vec![SURFACE_PORT]), + vec![OpacityInput::new(HALF, 0.5)], + vec![ + Paint::Solid { + id: LOWER_PAINT, + color: LOWER_COLOR, + }, + Paint::Solid { + id: UPPER_SOLID, + color: UPPER_COLOR, + }, + Paint::Opacity { + id: UPPER_PAINT, + source: UPPER_SOLID, + opacity: HALF, + }, + ], + vec![ + Surface::Input { + id: ROOT, + input: SURFACE_PORT, + }, + Surface::FromOccurrence { + id: DERIVED, + occurrence: LOWER, + }, + ], + vec![ + Occurrence::new( + LOWER, + LOWER_PAINT, + ROOT, + CompositionProfile::EncodedSrgb8SourceOverV1, + ), + Occurrence::new( + UPPER, + UPPER_PAINT, + DERIVED, + CompositionProfile::EncodedSrgb8SourceOverV1, + ), + ], + ConstraintSet::new( + vec![ConstraintInvocation::hard( + REQUIRED, + UPPER, + Srgb8::new([0xC0; 3]), + )], + vec![], + ), + vec![OutputBinding::new(NESTED_OUTPUT, UPPER_PAINT)], + ExactSrgb8IdentityV1, + ); + let compiled = program.compile().unwrap(); + let mut session = compiled.instantiate(STREAM_A).unwrap(); + let state = session + .update(observed_update(STREAM_A, 1, &[(1, [0x00; 3])])) + .unwrap(); + let SessionState::Ready { current } = state else { + panic!("upper occurrence must compose over the lower visible result"); + }; + assert!(!current.report().cells()[0].result().is_violation()); + let [output] = current.outputs() else { + panic!("nested program must emit its Paint, not visible composite"); + }; + assert_eq!(output.output(), NESTED_OUTPUT); + assert_eq!(output.paint().id(), UPPER_PAINT); + assert_eq!(output.paint().source(), Srgb8::new([0xFF; 3])); +} + +#[test] +fn raw_head_and_program_report_share_one_observation_backing() { + let compiled = exact_compiled(ConstraintSet::new( + vec![ConstraintInvocation::hard( + REQUIRED, + OCCURRENCE, + Srgb8::new([0x80; 3]), + )], + vec![], + )); + let mut session = compiled.instantiate(STREAM_A).unwrap(); + session + .update(observed_update(STREAM_A, 1, &[(9, [0xFF; 3])])) + .unwrap(); + let ObservationHeadViewV1::Observed(raw) = session.raw_head() else { + panic!("successful observed update must own a raw observed head"); + }; + let SessionState::Ready { current } = session.state() else { + panic!("fixture must verify"); + }; + let report = current.report().observation(); + assert_eq!(raw, report); + assert_eq!(raw.backing_ptr_for_test(), report.backing_ptr_for_test()); + assert_eq!(raw.schema_ptr_for_test(), report.schema_ptr_for_test()); + assert_eq!( + raw.physical_values(0).unwrap().as_ptr(), + report.physical_values(0).unwrap().as_ptr(), + ); + assert_eq!( + raw.provenance(0).unwrap().as_ptr(), + report.provenance(0).unwrap().as_ptr(), + ); +} diff --git a/crates/labcolors-core/src/release_registry.rs b/crates/labcolors-core/src/release_registry.rs new file mode 100644 index 00000000..e101b737 --- /dev/null +++ b/crates/labcolors-core/src/release_registry.rs @@ -0,0 +1,575 @@ +//! Minimal machine-readable registry for the F0 color-model releases. +//! +//! Registry descriptors contain only facts fixed by the implemented code: +//! context consumption, admitted frame/domain, coordinate units, achromatic +//! law, formula/reference identity and typed release dependencies. The absence +//! of a difference calibration is an explicit keyless row, not a placeholder +//! calibration. No empirical applicability, validation, uncertainty or +//! observer-study data is inferred here. + +use crate::lcs_occurrence::{ + ADMITTED_SRGB8_TRISTIMULUS_BINDING_V1, AdmittedSrgb8TristimulusBindingV1, + AppearanceContextSchemaReleaseId, CAM16_VIEW_RELEASE_V1, Cam16ViewReleaseId, + ColorimetricFrameId, IEC_SRGB_D65_XYZ_FRAME_V1, OKLAB_VIEW_RELEASE_V1, OklabViewReleaseId, +}; +use crate::output_projection::{ + CSS_COLOR_4_OKLCH_D65_FROM_MODELED_SRGB8_SOLID_V1, CssOklchHueSerializationReleaseIdV1, + CssOklchNumberEncodingReleaseIdV1, DifferenceCalibrationReleaseIdV1, OKLCH_VIEW_RELEASE_V1, + OklchViewReleaseId, OutputGamutTreatmentV1, OutputProjectionReleaseIdV1, +}; + +pub(crate) const RELEASE_REGISTRY_SCHEMA_VERSION_V1: u16 = 1; + +// Registered rows append a fixed-width 15-byte descriptor after the release +// key. The byte order is documented by `RegisteredReleaseDescriptorV1` below. +const RELEASE_REGISTRY_CANONICAL_BYTES_V1: &[u8] = concat!( + "labcolors.release-registry.canonical-binary.v1\0", + "\0\x01\0\x05", + "\x01\x01\0\x26cam16-li-et-al-2017-cie-248-forward-v1", + "\x01\x02\x01\x01\x01\x02\x02\x02\0\0\0\0\0\0\0", + "\x01\x01\0\x24oklab-ottosson-2021-01-25-xyz-d65-v1", + "\x01\x01\0\x01\x01\x01\x01\x01\0\0\0\0\0\0\0", + "\x01\x01\0\x27polar-from-ottosson-2021-01-25-oklab-v1", + "\x01\x01\0\x01\x02\x03\x03\x03\x01\0\x01\0\0\0\0", + "\x02\0\0\0", + "\x03\x01\0\x3acss-color-4-oklch-d65-from-modeled-iec61966-srgb8-solid-v1", + "\x01\x03\0\x01\x03\x04\x04\x04\x02\x01\x01\x01\x01\x01\x01", +) +.as_bytes(); + +const RELEASE_REGISTRY_FNV1A32_V1: u32 = 1_293_630_307; + +/// The disjoint kinds whose availability is reported by this registry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum ReleaseRegistryClassV1 { + AppearanceView, + DifferenceCalibration, + OutputProjection, +} + +impl ReleaseRegistryClassV1 { + pub(crate) const fn canonical_tag(self) -> u8 { + match self { + Self::AppearanceView => 1, + Self::DifferenceCalibration => 2, + Self::OutputProjection => 3, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum ReleaseRegistryAvailabilityV1 { + Unavailable, + Registered, +} + +impl ReleaseRegistryAvailabilityV1 { + pub(crate) const fn canonical_tag(self) -> u8 { + match self { + Self::Unavailable => 0, + Self::Registered => 1, + } + } +} + +/// A nominal link to one release implemented by the current F0 code. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum RegisteredColorReleaseIdV1 { + Cam16View(Cam16ViewReleaseId), + OklabView(OklabViewReleaseId), + OklchView(OklchViewReleaseId), + CssColor4OklchD65FromModeledSrgb8Solid(OutputProjectionReleaseIdV1), +} + +impl RegisteredColorReleaseIdV1 { + pub(crate) const fn class(self) -> ReleaseRegistryClassV1 { + match self { + Self::Cam16View(_) | Self::OklabView(_) | Self::OklchView(_) => { + ReleaseRegistryClassV1::AppearanceView + } + Self::CssColor4OklchD65FromModeledSrgb8Solid(_) => { + ReleaseRegistryClassV1::OutputProjection + } + } + } + + /// Stable ASCII key for the exact formula/operation-order release. + pub(crate) const fn key(self) -> &'static str { + match self { + Self::Cam16View(Cam16ViewReleaseId::LiEtAl2017Cie248ForwardV1) => { + "cam16-li-et-al-2017-cie-248-forward-v1" + } + Self::OklabView(OklabViewReleaseId::Ottosson20210125XyzD65V1) => { + "oklab-ottosson-2021-01-25-xyz-d65-v1" + } + Self::OklchView(OklchViewReleaseId::PolarFromOttosson20210125OklabV1) => { + "polar-from-ottosson-2021-01-25-oklab-v1" + } + Self::CssColor4OklchD65FromModeledSrgb8Solid(release) => release.key(), + } + } +} + +/// Whether and how a release consumes the occurrence's appearance context. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum ReleaseContextRequirementV1 { + NoAppearanceContextConsumptionV1, + ConsumesAppearanceContextV1(AppearanceContextSchemaReleaseId), + RetainsOccurrenceContextWithoutGeometryConsumptionV1, +} + +impl ReleaseContextRequirementV1 { + pub(crate) const fn schema_release(self) -> Option { + match self { + Self::ConsumesAppearanceContextV1(schema) => Some(schema), + Self::NoAppearanceContextConsumptionV1 + | Self::RetainsOccurrenceContextWithoutGeometryConsumptionV1 => None, + } + } + + const fn canonical_tag(self) -> u8 { + match self { + Self::NoAppearanceContextConsumptionV1 => 1, + Self::ConsumesAppearanceContextV1( + AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1, + ) => 2, + Self::RetainsOccurrenceContextWithoutGeometryConsumptionV1 => 3, + } + } + + const fn schema_tag(self) -> u8 { + match self.schema_release() { + None => 0, + Some(AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1) => 1, + } + } +} + +/// Exact colorimetric frame admitted by every current registered release. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum RegistryAdmittedFrameV1 { + Cie1931TwoDegreeXyzIecD65RelativeY1V1, +} + +impl RegistryAdmittedFrameV1 { + pub(crate) const fn frame(self) -> ColorimetricFrameId { + match self { + Self::Cie1931TwoDegreeXyzIecD65RelativeY1V1 => IEC_SRGB_D65_XYZ_FRAME_V1, + } + } + + const fn canonical_tag(self) -> u8 { + match self { + Self::Cie1931TwoDegreeXyzIecD65RelativeY1V1 => 1, + } + } +} + +/// Code-admitted input domain; this carries no empirical applicability claim. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum RegistryAdmittedDomainV1 { + FiniteNonNegativeXyzStimulusV1, + FiniteOklabRectangularViewV1, + ModeledIec61966Srgb8OccurrenceV1, +} + +impl RegistryAdmittedDomainV1 { + const fn canonical_tag(self) -> u8 { + match self { + Self::FiniteNonNegativeXyzStimulusV1 => 1, + Self::FiniteOklabRectangularViewV1 => 2, + Self::ModeledIec61966Srgb8OccurrenceV1 => 3, + } + } +} + +/// Coordinate/output units fixed by the registered code release. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum RegistryCoordinateUnitsV1 { + OklabCoordinatesUnitlessV1, + Cam16CorrelatesUnitlessHueDegreesV1, + OklchCoordinatesUnitlessHueDegreesV1, + CssColor4OklchPercentLightnessNumericChromaHueDegreesV1, +} + +impl RegistryCoordinateUnitsV1 { + const fn canonical_tag(self) -> u8 { + match self { + Self::OklabCoordinatesUnitlessV1 => 1, + Self::Cam16CorrelatesUnitlessHueDegreesV1 => 2, + Self::OklchCoordinatesUnitlessHueDegreesV1 => 3, + Self::CssColor4OklchPercentLightnessNumericChromaHueDegreesV1 => 4, + } + } +} + +/// Exact hue/achromatic behavior implemented by a release. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum RegistryAchromaticLawV1 { + NoHueCoordinateV1, + HueUndefinedExactlyWhenCam16MIsZeroV1, + HueUndefinedExactlyWhenOklabAAndBAreZeroV1, + ExactSourceGreyOrRectangularOriginSerializesHueZeroV1, +} + +impl RegistryAchromaticLawV1 { + const fn canonical_tag(self) -> u8 { + match self { + Self::NoHueCoordinateV1 => 1, + Self::HueUndefinedExactlyWhenCam16MIsZeroV1 => 2, + Self::HueUndefinedExactlyWhenOklabAAndBAreZeroV1 => 3, + Self::ExactSourceGreyOrRectangularOriginSerializesHueZeroV1 => 4, + } + } +} + +/// Formula/specification identity only, never empirical validation metadata. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum RegistryReferenceIdentityV1 { + Ottosson20210125OklabXyzD65V1, + LiEtAl2017Cie248Cam16ForwardV1, + Ottosson20210125OklabPolarV1, + CssColor4OklchD65V1, +} + +impl RegistryReferenceIdentityV1 { + const fn canonical_tag(self) -> u8 { + match self { + Self::Ottosson20210125OklabXyzD65V1 => 1, + Self::LiEtAl2017Cie248Cam16ForwardV1 => 2, + Self::Ottosson20210125OklabPolarV1 => 3, + Self::CssColor4OklchD65V1 => 4, + } + } +} + +/// Every typed edge executed by the current CSS output projection. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) struct OutputProjectionDependencyGraphV1 { + modeled_source_binding: AdmittedSrgb8TristimulusBindingV1, + oklab_view: OklabViewReleaseId, + oklch_view: OklchViewReleaseId, + number_encoding: CssOklchNumberEncodingReleaseIdV1, + hue_serialization: CssOklchHueSerializationReleaseIdV1, + gamut_treatment: OutputGamutTreatmentV1, +} + +impl OutputProjectionDependencyGraphV1 { + const fn registered_v1() -> Self { + Self { + modeled_source_binding: ADMITTED_SRGB8_TRISTIMULUS_BINDING_V1, + oklab_view: OKLAB_VIEW_RELEASE_V1, + oklch_view: OKLCH_VIEW_RELEASE_V1, + number_encoding: CssOklchNumberEncodingReleaseIdV1::LPercent5C6Hue3V1, + hue_serialization: + CssOklchHueSerializationReleaseIdV1::ExactSourceGreyOrRectangularOriginToZeroV1, + gamut_treatment: OutputGamutTreatmentV1::NoExplicitProjectionGamutMapV1, + } + } + + pub(crate) const fn modeled_source_binding(self) -> AdmittedSrgb8TristimulusBindingV1 { + self.modeled_source_binding + } + + pub(crate) const fn oklab_view(self) -> OklabViewReleaseId { + self.oklab_view + } + + pub(crate) const fn oklch_view(self) -> OklchViewReleaseId { + self.oklch_view + } + + pub(crate) const fn number_encoding(self) -> CssOklchNumberEncodingReleaseIdV1 { + self.number_encoding + } + + pub(crate) const fn hue_serialization(self) -> CssOklchHueSerializationReleaseIdV1 { + self.hue_serialization + } + + pub(crate) const fn gamut_treatment(self) -> OutputGamutTreatmentV1 { + self.gamut_treatment + } +} + +/// Typed release-to-release prerequisites; no free-form dependency keys exist. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum ReleaseDependencyGraphV1 { + DirectV1, + OklchPolarFromOklabV1(OklabViewReleaseId), + CssColor4OklchD65V1(OutputProjectionDependencyGraphV1), +} + +impl ReleaseDependencyGraphV1 { + /// Fixed canonical fields: + /// `[graph, source-binding, Oklab, Oklch, number, hue-policy, gamut-policy]`. + const fn canonical_fields(self) -> [u8; 7] { + match self { + Self::DirectV1 => [0; 7], + Self::OklchPolarFromOklabV1(OklabViewReleaseId::Ottosson20210125XyzD65V1) => { + [1, 0, 1, 0, 0, 0, 0] + } + Self::CssColor4OklchD65V1(graph) => [ + 2, + match graph.modeled_source_binding { + AdmittedSrgb8TristimulusBindingV1::Iec61966Srgb8ToCie1931TwoDegreeXyzD65RelativeY1V1 => 1, + }, + match graph.oklab_view { + OklabViewReleaseId::Ottosson20210125XyzD65V1 => 1, + }, + match graph.oklch_view { + OklchViewReleaseId::PolarFromOttosson20210125OklabV1 => 1, + }, + match graph.number_encoding { + CssOklchNumberEncodingReleaseIdV1::LPercent5C6Hue3V1 => 1, + }, + match graph.hue_serialization { + CssOklchHueSerializationReleaseIdV1::ExactSourceGreyOrRectangularOriginToZeroV1 => 1, + }, + match graph.gamut_treatment { + OutputGamutTreatmentV1::NoExplicitProjectionGamutMapV1 => 1, + }, + ], + } + } +} + +/// Complete code-truth descriptor for one registered release. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) struct RegisteredReleaseDescriptorV1 { + release: RegisteredColorReleaseIdV1, + context_requirement: ReleaseContextRequirementV1, + admitted_frame: RegistryAdmittedFrameV1, + admitted_domain: RegistryAdmittedDomainV1, + coordinate_units: RegistryCoordinateUnitsV1, + achromatic_law: RegistryAchromaticLawV1, + reference_identity: RegistryReferenceIdentityV1, + dependencies: ReleaseDependencyGraphV1, +} + +impl RegisteredReleaseDescriptorV1 { + pub(crate) const fn release(self) -> RegisteredColorReleaseIdV1 { + self.release + } + + pub(crate) const fn context_requirement(self) -> ReleaseContextRequirementV1 { + self.context_requirement + } + + pub(crate) const fn admitted_frame(self) -> RegistryAdmittedFrameV1 { + self.admitted_frame + } + + pub(crate) const fn admitted_domain(self) -> RegistryAdmittedDomainV1 { + self.admitted_domain + } + + pub(crate) const fn coordinate_units(self) -> RegistryCoordinateUnitsV1 { + self.coordinate_units + } + + pub(crate) const fn achromatic_law(self) -> RegistryAchromaticLawV1 { + self.achromatic_law + } + + pub(crate) const fn reference_identity(self) -> RegistryReferenceIdentityV1 { + self.reference_identity + } + + pub(crate) const fn dependencies(self) -> ReleaseDependencyGraphV1 { + self.dependencies + } + + /// Fixed-width descriptor bytes: + /// + /// `schema, context, context-schema, frame, domain, units, achromatic, + /// reference, dependency-graph, source-binding, Oklab, Oklch, number, + /// hue-policy, gamut-policy`. + pub(crate) const fn canonical_fields(self) -> [u8; 15] { + let dependencies = self.dependencies.canonical_fields(); + [ + 1, + self.context_requirement.canonical_tag(), + self.context_requirement.schema_tag(), + self.admitted_frame.canonical_tag(), + self.admitted_domain.canonical_tag(), + self.coordinate_units.canonical_tag(), + self.achromatic_law.canonical_tag(), + self.reference_identity.canonical_tag(), + dependencies[0], + dependencies[1], + dependencies[2], + dependencies[3], + dependencies[4], + dependencies[5], + dependencies[6], + ] + } +} + +const CAM16_VIEW_DESCRIPTOR_V1: RegisteredReleaseDescriptorV1 = RegisteredReleaseDescriptorV1 { + release: RegisteredColorReleaseIdV1::Cam16View(CAM16_VIEW_RELEASE_V1), + context_requirement: ReleaseContextRequirementV1::ConsumesAppearanceContextV1( + AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1, + ), + admitted_frame: RegistryAdmittedFrameV1::Cie1931TwoDegreeXyzIecD65RelativeY1V1, + admitted_domain: RegistryAdmittedDomainV1::FiniteNonNegativeXyzStimulusV1, + coordinate_units: RegistryCoordinateUnitsV1::Cam16CorrelatesUnitlessHueDegreesV1, + achromatic_law: RegistryAchromaticLawV1::HueUndefinedExactlyWhenCam16MIsZeroV1, + reference_identity: RegistryReferenceIdentityV1::LiEtAl2017Cie248Cam16ForwardV1, + dependencies: ReleaseDependencyGraphV1::DirectV1, +}; + +const OKLAB_VIEW_DESCRIPTOR_V1: RegisteredReleaseDescriptorV1 = RegisteredReleaseDescriptorV1 { + release: RegisteredColorReleaseIdV1::OklabView(OKLAB_VIEW_RELEASE_V1), + context_requirement: ReleaseContextRequirementV1::NoAppearanceContextConsumptionV1, + admitted_frame: RegistryAdmittedFrameV1::Cie1931TwoDegreeXyzIecD65RelativeY1V1, + admitted_domain: RegistryAdmittedDomainV1::FiniteNonNegativeXyzStimulusV1, + coordinate_units: RegistryCoordinateUnitsV1::OklabCoordinatesUnitlessV1, + achromatic_law: RegistryAchromaticLawV1::NoHueCoordinateV1, + reference_identity: RegistryReferenceIdentityV1::Ottosson20210125OklabXyzD65V1, + dependencies: ReleaseDependencyGraphV1::DirectV1, +}; + +const OKLCH_VIEW_DESCRIPTOR_V1: RegisteredReleaseDescriptorV1 = RegisteredReleaseDescriptorV1 { + release: RegisteredColorReleaseIdV1::OklchView(OKLCH_VIEW_RELEASE_V1), + context_requirement: ReleaseContextRequirementV1::NoAppearanceContextConsumptionV1, + admitted_frame: RegistryAdmittedFrameV1::Cie1931TwoDegreeXyzIecD65RelativeY1V1, + admitted_domain: RegistryAdmittedDomainV1::FiniteOklabRectangularViewV1, + coordinate_units: RegistryCoordinateUnitsV1::OklchCoordinatesUnitlessHueDegreesV1, + achromatic_law: RegistryAchromaticLawV1::HueUndefinedExactlyWhenOklabAAndBAreZeroV1, + reference_identity: RegistryReferenceIdentityV1::Ottosson20210125OklabPolarV1, + dependencies: ReleaseDependencyGraphV1::OklchPolarFromOklabV1(OKLAB_VIEW_RELEASE_V1), +}; + +const OUTPUT_PROJECTION_DESCRIPTOR_V1: RegisteredReleaseDescriptorV1 = + RegisteredReleaseDescriptorV1 { + release: RegisteredColorReleaseIdV1::CssColor4OklchD65FromModeledSrgb8Solid( + CSS_COLOR_4_OKLCH_D65_FROM_MODELED_SRGB8_SOLID_V1, + ), + context_requirement: + ReleaseContextRequirementV1::RetainsOccurrenceContextWithoutGeometryConsumptionV1, + admitted_frame: RegistryAdmittedFrameV1::Cie1931TwoDegreeXyzIecD65RelativeY1V1, + admitted_domain: RegistryAdmittedDomainV1::ModeledIec61966Srgb8OccurrenceV1, + coordinate_units: + RegistryCoordinateUnitsV1::CssColor4OklchPercentLightnessNumericChromaHueDegreesV1, + achromatic_law: + RegistryAchromaticLawV1::ExactSourceGreyOrRectangularOriginSerializesHueZeroV1, + reference_identity: RegistryReferenceIdentityV1::CssColor4OklchD65V1, + dependencies: ReleaseDependencyGraphV1::CssColor4OklchD65V1( + OutputProjectionDependencyGraphV1::registered_v1(), + ), + }; + +/// One closed registry row. +/// +/// The unavailable variant carries no descriptor or release identifier, so it +/// cannot be mistaken for an admitted difference formula. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum ReleaseRegistryRecordV1 { + Registered(RegisteredReleaseDescriptorV1), + DifferenceCalibrationUnavailable, +} + +impl ReleaseRegistryRecordV1 { + pub(crate) const fn class(self) -> ReleaseRegistryClassV1 { + match self { + Self::Registered(descriptor) => descriptor.release().class(), + Self::DifferenceCalibrationUnavailable => ReleaseRegistryClassV1::DifferenceCalibration, + } + } + + pub(crate) const fn availability(self) -> ReleaseRegistryAvailabilityV1 { + match self { + Self::Registered(_) => ReleaseRegistryAvailabilityV1::Registered, + Self::DifferenceCalibrationUnavailable => ReleaseRegistryAvailabilityV1::Unavailable, + } + } + + pub(crate) const fn release(self) -> Option { + match self { + Self::Registered(descriptor) => Some(descriptor.release()), + Self::DifferenceCalibrationUnavailable => None, + } + } + + pub(crate) const fn descriptor(self) -> Option { + match self { + Self::Registered(descriptor) => Some(descriptor), + Self::DifferenceCalibrationUnavailable => None, + } + } +} + +// Canonical order is class tag, then release-key bytes. An unavailable class +// has exactly one keyless and descriptor-less row. +const RELEASE_REGISTRY_RECORDS_V1: [ReleaseRegistryRecordV1; 5] = [ + ReleaseRegistryRecordV1::Registered(CAM16_VIEW_DESCRIPTOR_V1), + ReleaseRegistryRecordV1::Registered(OKLAB_VIEW_DESCRIPTOR_V1), + ReleaseRegistryRecordV1::Registered(OKLCH_VIEW_DESCRIPTOR_V1), + ReleaseRegistryRecordV1::DifferenceCalibrationUnavailable, + ReleaseRegistryRecordV1::Registered(OUTPUT_PROJECTION_DESCRIPTOR_V1), +]; + +pub(crate) const fn release_registry_records_v1() -> &'static [ReleaseRegistryRecordV1] { + &RELEASE_REGISTRY_RECORDS_V1 +} + +/// The existing difference-release type is uninhabited in this registry. +pub(crate) const fn impossible_difference_calibration_release_v1( + release: DifferenceCalibrationReleaseIdV1, +) -> ! { + match release {} +} + +/// Canonical binary encoding of the complete registry. +/// +/// Layout is `magic || schema:u16be || rows:u16be`, followed by rows in the +/// declared canonical order. Every row starts with +/// `class:u8 || availability:u8 || key_length:u16be || key:utf8`. A registered +/// row then carries the 15 descriptor bytes documented by +/// [`RegisteredReleaseDescriptorV1::canonical_fields`]. The unavailable +/// difference row has a zero key length and no descriptor bytes. +pub(crate) const fn release_registry_canonical_bytes_v1() -> &'static [u8] { + RELEASE_REGISTRY_CANONICAL_BYTES_V1 +} + +/// Explicit algorithm identity for the registry's non-cryptographic drift +/// sentinel. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum ReleaseRegistryDigestAlgorithmV1 { + Fnv1a32V1, +} + +impl ReleaseRegistryDigestAlgorithmV1 { + pub(crate) const fn key(self) -> &'static str { + match self { + Self::Fnv1a32V1 => "fnv1a-32-v1", + } + } +} + +/// A deterministic drift sentinel, not cryptographic evidence or an +/// authenticity/content-identity claim. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) struct ReleaseRegistryDigestV1 { + algorithm: ReleaseRegistryDigestAlgorithmV1, + value: u32, +} + +impl ReleaseRegistryDigestV1 { + pub(crate) const fn algorithm(self) -> ReleaseRegistryDigestAlgorithmV1 { + self.algorithm + } + + pub(crate) const fn value(self) -> u32 { + self.value + } +} + +pub(crate) const fn release_registry_digest_v1() -> ReleaseRegistryDigestV1 { + ReleaseRegistryDigestV1 { + algorithm: ReleaseRegistryDigestAlgorithmV1::Fnv1a32V1, + value: RELEASE_REGISTRY_FNV1A32_V1, + } +} diff --git a/crates/labcolors-core/src/release_registry_tests.rs b/crates/labcolors-core/src/release_registry_tests.rs new file mode 100644 index 00000000..1f1c40d0 --- /dev/null +++ b/crates/labcolors-core/src/release_registry_tests.rs @@ -0,0 +1,305 @@ +use crate::lcs_occurrence::{ + ADMITTED_SRGB8_TRISTIMULUS_BINDING_V1, AppearanceContextSchemaReleaseId, CAM16_VIEW_RELEASE_V1, + IEC_SRGB_D65_XYZ_FRAME_V1, OKLAB_VIEW_RELEASE_V1, +}; +use crate::output_projection::{ + CSS_COLOR_4_OKLCH_D65_FROM_MODELED_SRGB8_SOLID_V1, CssOklchHueSerializationReleaseIdV1, + CssOklchNumberEncodingReleaseIdV1, DifferenceCalibrationReleaseIdV1, OKLCH_VIEW_RELEASE_V1, + OutputGamutTreatmentV1, +}; +use crate::release_registry::{ + RELEASE_REGISTRY_SCHEMA_VERSION_V1, RegisteredColorReleaseIdV1, RegisteredReleaseDescriptorV1, + RegistryAchromaticLawV1, RegistryAdmittedDomainV1, RegistryAdmittedFrameV1, + RegistryCoordinateUnitsV1, RegistryReferenceIdentityV1, ReleaseContextRequirementV1, + ReleaseDependencyGraphV1, ReleaseRegistryAvailabilityV1, ReleaseRegistryClassV1, + ReleaseRegistryDigestAlgorithmV1, ReleaseRegistryRecordV1, + impossible_difference_calibration_release_v1, release_registry_canonical_bytes_v1, + release_registry_digest_v1, release_registry_records_v1, +}; + +fn descriptor(release: RegisteredColorReleaseIdV1) -> RegisteredReleaseDescriptorV1 { + release_registry_records_v1() + .iter() + .filter_map(|record| record.descriptor()) + .find(|descriptor| descriptor.release() == release) + .expect("registered release descriptor") +} + +#[test] +fn registry_contains_only_the_four_implemented_releases() { + let releases: Vec<_> = release_registry_records_v1() + .iter() + .filter_map(|record| record.release()) + .collect(); + assert_eq!( + releases, + [ + RegisteredColorReleaseIdV1::Cam16View(CAM16_VIEW_RELEASE_V1), + RegisteredColorReleaseIdV1::OklabView(OKLAB_VIEW_RELEASE_V1), + RegisteredColorReleaseIdV1::OklchView(OKLCH_VIEW_RELEASE_V1), + RegisteredColorReleaseIdV1::CssColor4OklchD65FromModeledSrgb8Solid( + CSS_COLOR_4_OKLCH_D65_FROM_MODELED_SRGB8_SOLID_V1, + ), + ], + ); +} + +#[test] +fn difference_registry_is_explicitly_unavailable_and_has_no_fake_descriptor() { + let row = release_registry_records_v1() + .iter() + .copied() + .find(|row| row.class() == ReleaseRegistryClassV1::DifferenceCalibration) + .expect("difference availability row"); + assert_eq!( + row, + ReleaseRegistryRecordV1::DifferenceCalibrationUnavailable + ); + assert_eq!( + row.availability(), + ReleaseRegistryAvailabilityV1::Unavailable + ); + assert_eq!(row.release(), None); + assert_eq!(row.descriptor(), None); + + let _: fn(DifferenceCalibrationReleaseIdV1) -> ! = impossible_difference_calibration_release_v1; +} + +#[test] +fn appearance_descriptors_pin_only_code_owned_domain_units_hue_and_reference_facts() { + let cam16 = descriptor(RegisteredColorReleaseIdV1::Cam16View(CAM16_VIEW_RELEASE_V1)); + assert_eq!( + cam16.context_requirement(), + ReleaseContextRequirementV1::ConsumesAppearanceContextV1( + AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1, + ), + ); + assert_eq!( + cam16.context_requirement().schema_release(), + Some(AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1), + ); + assert_eq!( + cam16.admitted_frame(), + RegistryAdmittedFrameV1::Cie1931TwoDegreeXyzIecD65RelativeY1V1, + ); + assert_eq!(cam16.admitted_frame().frame(), IEC_SRGB_D65_XYZ_FRAME_V1); + assert_eq!( + cam16.admitted_domain(), + RegistryAdmittedDomainV1::FiniteNonNegativeXyzStimulusV1, + ); + assert_eq!( + cam16.coordinate_units(), + RegistryCoordinateUnitsV1::Cam16CorrelatesUnitlessHueDegreesV1, + ); + assert_eq!( + cam16.achromatic_law(), + RegistryAchromaticLawV1::HueUndefinedExactlyWhenCam16MIsZeroV1, + ); + assert_eq!( + cam16.reference_identity(), + RegistryReferenceIdentityV1::LiEtAl2017Cie248Cam16ForwardV1, + ); + assert_eq!(cam16.dependencies(), ReleaseDependencyGraphV1::DirectV1); + + let oklab = descriptor(RegisteredColorReleaseIdV1::OklabView(OKLAB_VIEW_RELEASE_V1)); + assert_eq!( + oklab.context_requirement(), + ReleaseContextRequirementV1::NoAppearanceContextConsumptionV1, + ); + assert_eq!(oklab.context_requirement().schema_release(), None); + assert_eq!(oklab.admitted_frame().frame(), IEC_SRGB_D65_XYZ_FRAME_V1); + assert_eq!( + oklab.admitted_domain(), + RegistryAdmittedDomainV1::FiniteNonNegativeXyzStimulusV1, + ); + assert_eq!( + oklab.coordinate_units(), + RegistryCoordinateUnitsV1::OklabCoordinatesUnitlessV1, + ); + assert_eq!( + oklab.achromatic_law(), + RegistryAchromaticLawV1::NoHueCoordinateV1, + ); + assert_eq!( + oklab.reference_identity(), + RegistryReferenceIdentityV1::Ottosson20210125OklabXyzD65V1, + ); + assert_eq!(oklab.dependencies(), ReleaseDependencyGraphV1::DirectV1); + + let oklch = descriptor(RegisteredColorReleaseIdV1::OklchView(OKLCH_VIEW_RELEASE_V1)); + assert_eq!( + oklch.context_requirement(), + ReleaseContextRequirementV1::NoAppearanceContextConsumptionV1, + ); + assert_eq!(oklch.admitted_frame().frame(), IEC_SRGB_D65_XYZ_FRAME_V1); + assert_eq!( + oklch.admitted_domain(), + RegistryAdmittedDomainV1::FiniteOklabRectangularViewV1, + ); + assert_eq!( + oklch.coordinate_units(), + RegistryCoordinateUnitsV1::OklchCoordinatesUnitlessHueDegreesV1, + ); + assert_eq!( + oklch.achromatic_law(), + RegistryAchromaticLawV1::HueUndefinedExactlyWhenOklabAAndBAreZeroV1, + ); + assert_eq!( + oklch.reference_identity(), + RegistryReferenceIdentityV1::Ottosson20210125OklabPolarV1, + ); + assert_eq!( + oklch.dependencies(), + ReleaseDependencyGraphV1::OklchPolarFromOklabV1(OKLAB_VIEW_RELEASE_V1), + ); +} + +#[test] +fn output_descriptor_binds_the_complete_typed_projection_dependency_chain() { + let output = descriptor( + RegisteredColorReleaseIdV1::CssColor4OklchD65FromModeledSrgb8Solid( + CSS_COLOR_4_OKLCH_D65_FROM_MODELED_SRGB8_SOLID_V1, + ), + ); + assert_eq!( + output.context_requirement(), + ReleaseContextRequirementV1::RetainsOccurrenceContextWithoutGeometryConsumptionV1, + ); + assert_eq!(output.context_requirement().schema_release(), None); + assert_eq!(output.admitted_frame().frame(), IEC_SRGB_D65_XYZ_FRAME_V1); + assert_eq!( + output.admitted_domain(), + RegistryAdmittedDomainV1::ModeledIec61966Srgb8OccurrenceV1, + ); + assert_eq!( + output.coordinate_units(), + RegistryCoordinateUnitsV1::CssColor4OklchPercentLightnessNumericChromaHueDegreesV1, + ); + assert_eq!( + output.achromatic_law(), + RegistryAchromaticLawV1::ExactSourceGreyOrRectangularOriginSerializesHueZeroV1, + ); + assert_eq!( + output.reference_identity(), + RegistryReferenceIdentityV1::CssColor4OklchD65V1, + ); + + let ReleaseDependencyGraphV1::CssColor4OklchD65V1(dependencies) = output.dependencies() else { + panic!("output descriptor must carry its typed dependency graph"); + }; + assert_eq!( + dependencies.modeled_source_binding(), + ADMITTED_SRGB8_TRISTIMULUS_BINDING_V1, + ); + assert_eq!(dependencies.oklab_view(), OKLAB_VIEW_RELEASE_V1); + assert_eq!(dependencies.oklch_view(), OKLCH_VIEW_RELEASE_V1); + assert_eq!( + dependencies.number_encoding(), + CssOklchNumberEncodingReleaseIdV1::LPercent5C6Hue3V1, + ); + assert_eq!( + dependencies.hue_serialization(), + CssOklchHueSerializationReleaseIdV1::ExactSourceGreyOrRectangularOriginToZeroV1, + ); + assert_eq!( + dependencies.gamut_treatment(), + OutputGamutTreatmentV1::NoExplicitProjectionGamutMapV1, + ); +} + +#[test] +fn registered_rows_have_stable_exact_release_keys() { + let keys: Vec<_> = release_registry_records_v1() + .iter() + .filter_map(|row| row.release().map(RegisteredColorReleaseIdV1::key)) + .collect(); + assert_eq!( + keys, + [ + "cam16-li-et-al-2017-cie-248-forward-v1", + "oklab-ottosson-2021-01-25-xyz-d65-v1", + "polar-from-ottosson-2021-01-25-oklab-v1", + "css-color-4-oklch-d65-from-modeled-iec61966-srgb8-solid-v1", + ], + ); +} + +#[test] +fn canonical_bytes_pin_schema_rows_descriptors_dependencies_and_order() { + assert_eq!(RELEASE_REGISTRY_SCHEMA_VERSION_V1, 1); + assert_eq!( + release_registry_canonical_bytes_v1(), + concat!( + "labcolors.release-registry.canonical-binary.v1\0", + "\0\x01\0\x05", + "\x01\x01\0\x26cam16-li-et-al-2017-cie-248-forward-v1", + "\x01\x02\x01\x01\x01\x02\x02\x02\0\0\0\0\0\0\0", + "\x01\x01\0\x24oklab-ottosson-2021-01-25-xyz-d65-v1", + "\x01\x01\0\x01\x01\x01\x01\x01\0\0\0\0\0\0\0", + "\x01\x01\0\x27polar-from-ottosson-2021-01-25-oklab-v1", + "\x01\x01\0\x01\x02\x03\x03\x03\x01\0\x01\0\0\0\0", + "\x02\0\0\0", + "\x03\x01\0\x3acss-color-4-oklch-d65-from-modeled-iec61966-srgb8-solid-v1", + "\x01\x03\0\x01\x03\x04\x04\x04\x02\x01\x01\x01\x01\x01\x01", + ) + .as_bytes(), + ); +} + +#[test] +fn canonical_bytes_encode_every_typed_registry_row_and_descriptor_in_order() { + const MAGIC: &[u8] = b"labcolors.release-registry.canonical-binary.v1\0"; + + let bytes = release_registry_canonical_bytes_v1(); + assert_eq!(&bytes[..MAGIC.len()], MAGIC); + let mut cursor = MAGIC.len(); + let take_u16 = |cursor: &mut usize| { + let value = u16::from_be_bytes([bytes[*cursor], bytes[*cursor + 1]]); + *cursor += 2; + value + }; + + assert_eq!(take_u16(&mut cursor), RELEASE_REGISTRY_SCHEMA_VERSION_V1); + let records = release_registry_records_v1(); + assert_eq!(usize::from(take_u16(&mut cursor)), records.len()); + + for record in records { + assert_eq!(bytes[cursor], record.class().canonical_tag()); + cursor += 1; + assert_eq!(bytes[cursor], record.availability().canonical_tag()); + cursor += 1; + + let key_length = usize::from(take_u16(&mut cursor)); + let expected_key = record + .release() + .map(RegisteredColorReleaseIdV1::key) + .unwrap_or("") + .as_bytes(); + assert_eq!(key_length, expected_key.len()); + assert_eq!(&bytes[cursor..cursor + key_length], expected_key); + cursor += key_length; + + if let Some(descriptor) = record.descriptor() { + let fields = descriptor.canonical_fields(); + assert_eq!(&bytes[cursor..cursor + fields.len()], fields); + cursor += fields.len(); + } + } + + assert_eq!(cursor, bytes.len()); +} + +#[test] +fn digest_names_its_non_cryptographic_algorithm_and_covers_descriptor_bytes() { + let digest = release_registry_digest_v1(); + assert_eq!( + digest.algorithm(), + ReleaseRegistryDigestAlgorithmV1::Fnv1a32V1, + ); + assert_eq!(digest.algorithm().key(), "fnv1a-32-v1"); + assert_eq!( + digest.value(), + crate::fnv1a_32(release_registry_canonical_bytes_v1()), + ); + assert_eq!(digest.value(), 1_293_630_307); +} diff --git a/crates/labcolors-core/src/session.rs b/crates/labcolors-core/src/session.rs index e10d8003..2497219b 100644 --- a/crates/labcolors-core/src/session.rs +++ b/crates/labcolors-core/src/session.rs @@ -1,26 +1,29 @@ -//! Single lifecycle and observation owner for private F2/C8d full support. +//! Sole revision-bound runtime lifecycle for compiled point programs. //! -//! The closed state below owns the one current raw payload through either its -//! revision-bound report or its current `Unknown`. A separate raw head does not -//! exist; [`ObservationHeadViewV1`] is derived by borrow. At most one previous -//! verified report is retained and no transition builds a history chain. +//! [`Session`] owns the one concrete raw observation head and the one +//! evaluator lifecycle. A plan supplies only its canonical observation schema +//! and consuming evaluation; it cannot admit updates or commit lifecycle +//! state. The plan type is sealed and statically dispatched, so sharing this +//! lifecycle across compiled plans adds neither a runtime tag nor a trait +//! object. use std::mem; -use crate::composition::CompositionProfileV1; use crate::observation::{ - ObservationError, ObservationHeadViewV1, ObservationOwnerV1, ObservationSchemaMismatchV1, + CanonicalObservationSchemaV1, ObservationError, ObservationHeadViewV1, ObservationOwnerV1, ObservationStreamId, ObservationUpdateInput, PreparedObservationUpdateV1, - RevisionBoundUnknownV1, prepare_observation, -}; -use crate::point_support::{ - BoundPointSupportRecheckV1, CompiledPointSupportRecheckV1, PointSupportDecisionV1, - PointSupportEvaluationErrorV1, PointSupportViolationV1, VerifiedPointSupportV1, + RevisionBoundObservationV1, RevisionBoundUnknownV1, prepare_observation, }; -/// Linear authority to consume and revision-bind an observation. The type is -/// visible to the evaluator only as a parameter; its private field and private -/// constructor make safe construction exclusive to this Session module. +/// Crate-private sealing prevents an additional runtime owner from being +/// smuggled in through a public extension point. +pub(crate) mod private { + pub(crate) trait PlanSealed {} + pub(crate) trait EvidenceSealed {} +} + +/// Linear authority to revision-bind one admitted observation to evaluator +/// evidence. Safe construction remains exclusive to this module. pub(crate) struct SessionObservationBindingPermitV1 { _private: (), } @@ -36,370 +39,207 @@ impl SessionObservationBindingPermitV1 { } } -#[derive(Debug, PartialEq)] -#[cfg_attr(test, derive(Clone))] -pub(crate) enum PointSupportSessionStateV1 { - /// Initial state or a current Unknown without any previous verified report. - Waiting { - current_unknown: Option, - }, +/// Complete result of evaluating one admitted observation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum SessionDecision { + Verified(Verified), + Violation(Violation), +} + +pub(crate) trait SessionEvidenceV1: private::EvidenceSealed { + fn observation(&self) -> &RevisionBoundObservationV1; +} + +impl SessionDecision +where + Verified: SessionEvidenceV1, + Violation: SessionEvidenceV1, +{ + fn observation(&self) -> &RevisionBoundObservationV1 { + match self { + Self::Verified(evidence) => evidence.observation(), + Self::Violation(evidence) => evidence.observation(), + } + } +} + +/// A compiled, statically dispatched evaluator used by the sole [`Session`] +/// lifecycle. Implementations own their per-Session scratch directly. +pub(crate) trait SessionPlanV1: private::PlanSealed { + type Verified: SessionEvidenceV1; + type Violation: SessionEvidenceV1; + type Error; + + fn observation_schema(&self) -> &CanonicalObservationSchemaV1; + + fn evaluate( + &mut self, + observation: RevisionBoundObservationV1, + permit: SessionObservationBindingPermitV1, + ) -> Result, Self::Error>; +} + +/// Evaluator lifecycle. The current raw payload is deliberately not embedded +/// here: `Unknown` carries no evidence, while `Stale` retains at most one +/// previous verified witness. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum SessionState { + Waiting, Ready { - current: VerifiedPointSupportV1, + current: Verified, }, Stale { - previous: VerifiedPointSupportV1, - current_unknown: RevisionBoundUnknownV1, + previous: Verified, }, Failed { - cause: PointSupportViolationV1, - previous: Option, + cause: Violation, + previous: Option, }, } -impl PointSupportSessionStateV1 { - pub(crate) fn last_verified(&self) -> Option<&VerifiedPointSupportV1> { +impl SessionState { + pub(crate) fn last_verified(&self) -> Option<&Verified> { match self { - Self::Waiting { .. } => None, + Self::Waiting => None, Self::Ready { current } => Some(current), - Self::Stale { previous, .. } => Some(previous), + Self::Stale { previous } => Some(previous), Self::Failed { previous, .. } => previous.as_ref(), } } } -impl ObservationOwnerV1 for PointSupportSessionStateV1 { +#[derive(Debug, Clone, PartialEq, Eq)] +enum SessionObservationHeadV1 { + Empty, + Unknown(RevisionBoundUnknownV1), + Observed(RevisionBoundObservationV1), +} + +impl ObservationOwnerV1 for SessionObservationHeadV1 { fn observation_head(&self) -> ObservationHeadViewV1<'_> { match self { - Self::Waiting { - current_unknown: None, - } => ObservationHeadViewV1::Empty, - Self::Waiting { - current_unknown: Some(unknown), - } - | Self::Stale { - current_unknown: unknown, - .. - } => ObservationHeadViewV1::Unknown(unknown), - Self::Ready { current } => { - ObservationHeadViewV1::Observed(current.report().observation()) - } - Self::Failed { cause, .. } => { - ObservationHeadViewV1::Observed(cause.report().observation()) - } + Self::Empty => ObservationHeadViewV1::Empty, + Self::Unknown(unknown) => ObservationHeadViewV1::Unknown(unknown), + Self::Observed(observation) => ObservationHeadViewV1::Observed(observation), } } } +/// An update failed before either raw-head or lifecycle commit. #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum PointSupportSessionUpdateErrorV1 { +pub(crate) enum SessionUpdateError { Observation(ObservationError), - ObservationSchemaMismatch(ObservationSchemaMismatchV1), - ResourceExhausted, - InternalInvariant, + Plan(PlanError), + EvidenceBindingInvariant, } -#[derive(Debug, PartialEq)] -#[cfg_attr(test, derive(Clone))] -pub(crate) struct PointSupportSessionV1 { +type SessionUpdateResult<'session, Plan> = Result< + &'session SessionState<::Verified, ::Violation>, + SessionUpdateError<::Error>, +>; + +/// The only production owner of revision admission and evaluator lifecycle. +/// `Plan` is monomorphized; there is no plan enum, dynamic dispatch, adapter, +/// weak owner or expiration branch. +#[derive(Debug)] +pub(crate) struct Session { stream: ObservationStreamId, - recheck: BoundPointSupportRecheckV1, - state: PointSupportSessionStateV1, - #[cfg(test)] - force_resource_failure: bool, + schema: CanonicalObservationSchemaV1, + plan: Plan, + raw_head: SessionObservationHeadV1, + state: SessionState, } -impl PointSupportSessionV1 { - /// The compiled recheck owns the only canonical schema and is moved into - /// the Session. No second schema or replacement Paint can enter updates. - pub(crate) fn new( - stream: ObservationStreamId, - compiled: CompiledPointSupportRecheckV1, - ) -> Self { +impl Session { + pub(crate) fn new(stream: ObservationStreamId, plan: Plan) -> Self { + let schema = plan.observation_schema().clone(); Self { stream, - recheck: compiled.into_session_recheck(), - state: PointSupportSessionStateV1::Waiting { - current_unknown: None, - }, - #[cfg(test)] - force_resource_failure: false, + schema, + plan, + raw_head: SessionObservationHeadV1::Empty, + state: SessionState::Waiting, } } - pub(crate) const fn state(&self) -> &PointSupportSessionStateV1 { + pub(crate) const fn state(&self) -> &SessionState { &self.state } pub(crate) fn raw_head(&self) -> ObservationHeadViewV1<'_> { - self.state.observation_head() - } - - pub(crate) const fn composition_profile(&self) -> CompositionProfileV1 { - self.recheck.composition_profile() - } - - #[cfg(test)] - pub(crate) fn force_next_resource_failure(&mut self) { - self.force_resource_failure = true; + self.raw_head.observation_head() } - /// One transaction: prepare/canonicalize without mutation, transfer the - /// exact observation under a Session-only permit to the consuming - /// evaluator, then replace the closed owner with infallible moves only. + /// Prepare, evaluate and commit one update transaction. Admission and plan + /// errors leave both the concrete raw head and lifecycle state untouched. + /// Plan-local scratch may have been overwritten by a failed evaluation, + /// but it is not observable lifecycle state and every evaluation must + /// completely initialize the scratch it consumes. pub(crate) fn update( &mut self, update: ObservationUpdateInput, - ) -> Result<&PointSupportSessionStateV1, PointSupportSessionUpdateErrorV1> { - let prepared = prepare_observation( - &mut self.state, - self.stream, - self.recheck.surface_schema(), - update, - ) - .map_err(PointSupportSessionUpdateErrorV1::Observation)?; + ) -> SessionUpdateResult<'_, Plan> { + let prepared = prepare_observation(&mut self.raw_head, self.stream, &self.schema, update) + .map_err(SessionUpdateError::Observation)?; match prepared { - PreparedObservationUpdateV1::Idempotent(prepared) => Ok(prepared.into_owner()), + PreparedObservationUpdateV1::Idempotent(prepared) => { + let _raw_head = prepared.into_owner(); + Ok(&self.state) + } PreparedObservationUpdateV1::Unknown(prepared) => { - let (state, unknown) = prepared.into_parts(); - let previous = take_last_verified(state); - *state = match previous { - Some(previous) => PointSupportSessionStateV1::Stale { - previous, - current_unknown: unknown, - }, - None => PointSupportSessionStateV1::Waiting { - current_unknown: Some(unknown), - }, + let (raw_head, unknown) = prepared.into_parts(); + let next_state = match take_last_verified(&mut self.state) { + Some(previous) => SessionState::Stale { previous }, + None => SessionState::Waiting, }; - Ok(state) + *raw_head = SessionObservationHeadV1::Unknown(unknown); + self.state = next_state; + Ok(&self.state) } PreparedObservationUpdateV1::Observed(prepared) => { - #[cfg(test)] - if mem::take(&mut self.force_resource_failure) { - return Err(PointSupportSessionUpdateErrorV1::ResourceExhausted); - } - - // Evaluation consumes the exact admitted observation and can - // return only an already revision-bound decision. The mutable - // owner is retained unchanged until that fallible work succeeds. - let (state, observation) = prepared.into_parts(); + // Clone only the small Rc-backed observation handle. Both the + // committed raw head and returned evidence then share the exact + // immutable observation backing. + let (raw_head, observation) = prepared.into_parts(); + let next_raw_head = SessionObservationHeadV1::Observed(observation.clone()); let decision = self - .recheck + .plan .evaluate(observation, SessionObservationBindingPermitV1::mint()) - .map_err(map_evaluation_error)?; - let previous = take_last_verified(state); - *state = match decision { - PointSupportDecisionV1::Verified(current) => { - PointSupportSessionStateV1::Ready { current } - } - PointSupportDecisionV1::Violation(cause) => { - PointSupportSessionStateV1::Failed { cause, previous } - } + .map_err(SessionUpdateError::Plan)?; + let SessionObservationHeadV1::Observed(expected_observation) = &next_raw_head + else { + unreachable!("the pending raw head was constructed as Observed") }; - Ok(state) - } - } - } -} + if !decision + .observation() + .is_same_binding_as(expected_observation) + { + return Err(SessionUpdateError::EvidenceBindingInvariant); + } -fn map_evaluation_error(error: PointSupportEvaluationErrorV1) -> PointSupportSessionUpdateErrorV1 { - match error { - PointSupportEvaluationErrorV1::ObservationSchemaMismatch(mismatch) => { - PointSupportSessionUpdateErrorV1::ObservationSchemaMismatch(mismatch) - } - PointSupportEvaluationErrorV1::ResourceExhausted => { - PointSupportSessionUpdateErrorV1::ResourceExhausted - } - PointSupportEvaluationErrorV1::CompiledPlanInvariant - | PointSupportEvaluationErrorV1::Wcag22Invariant - | PointSupportEvaluationErrorV1::StabilityArithmeticInvariant => { - PointSupportSessionUpdateErrorV1::InternalInvariant + // All fallible work is complete. Commit with moves only. + let previous = take_last_verified(&mut self.state); + let next_state = match decision { + SessionDecision::Verified(current) => SessionState::Ready { current }, + SessionDecision::Violation(cause) => SessionState::Failed { cause, previous }, + }; + *raw_head = next_raw_head; + self.state = next_state; + Ok(&self.state) + } } } } /// Move exactly one retained verified witness out of the old closed owner. -fn take_last_verified(state: &mut PointSupportSessionStateV1) -> Option { - match mem::replace( - state, - PointSupportSessionStateV1::Waiting { - current_unknown: None, - }, - ) { - PointSupportSessionStateV1::Waiting { .. } => None, - PointSupportSessionStateV1::Ready { current } => Some(current), - PointSupportSessionStateV1::Stale { previous, .. } => Some(previous), - PointSupportSessionStateV1::Failed { previous, .. } => previous, - } -} - -#[cfg(test)] -mod structural_tests { - use super::SessionObservationBindingPermitV1; - use crate::Srgb8; - use crate::appearance::{EncodedPointPaintV1, OccurrenceId, PaintId, SurfaceInputPortId}; - use crate::composition::{AdmittedOpacityV1, CompositionProfileV1}; - use crate::observation::{ - ObservationHeadViewV1, ObservationOwnerV1, ObservationPayloadInput, - ObservationSchemaMismatchV1, ObservationStreamId, ObservationUpdateInput, - ObservedScenarioSetInput, PreparedObservationUpdateV1, Revision, - RevisionBoundObservationV1, ScenarioId, ScenarioInput, SurfaceInputBinding, - prepare_observation, - }; - use crate::point_support::{ - CompiledPointSupportRecheckV1, PointSupportCriterionRequirementV1, - PointSupportEvaluationErrorV1, PointSupportOccurrenceRequirementV1, - PointSupportStabilityPolicyV1, - }; - - const STREAM: ObservationStreamId = ObservationStreamId::new(700); - const REQUIRED_SURFACE: SurfaceInputPortId = SurfaceInputPortId::new(10); - const WRONG_SURFACE: SurfaceInputPortId = SurfaceInputPortId::new(20); - - struct EmptyOwner; - - impl ObservationOwnerV1 for EmptyOwner { - fn observation_head(&self) -> ObservationHeadViewV1<'_> { - ObservationHeadViewV1::Empty - } - } - - fn wrong_schema_observation() -> RevisionBoundObservationV1 { - let mut owner = EmptyOwner; - let prepared = prepare_observation( - &mut owner, - STREAM, - &[WRONG_SURFACE], - ObservationUpdateInput { - stream: STREAM, - revision: Revision::new(1), - payload: ObservationPayloadInput::Scenarios(ObservedScenarioSetInput { - scenarios: vec![ScenarioInput { - id: ScenarioId::new(1), - bindings: vec![SurfaceInputBinding::new( - WRONG_SURFACE, - Srgb8::new([255; 3]), - )], - }], - }), - }, - ) - .unwrap(); - let PreparedObservationUpdateV1::Observed(prepared) = prepared else { - panic!("fresh observed update must prepare an observation"); - }; - let (_owner, observation) = prepared.into_parts(); - observation - } - - fn narrow_schema_observation() -> RevisionBoundObservationV1 { - let mut owner = EmptyOwner; - let prepared = prepare_observation( - &mut owner, - STREAM, - &[REQUIRED_SURFACE], - ObservationUpdateInput { - stream: STREAM, - revision: Revision::new(1), - payload: ObservationPayloadInput::Scenarios(ObservedScenarioSetInput { - scenarios: vec![ScenarioInput { - id: ScenarioId::new(1), - bindings: vec![SurfaceInputBinding::new( - REQUIRED_SURFACE, - Srgb8::new([255; 3]), - )], - }], - }), - }, - ) - .unwrap(); - let PreparedObservationUpdateV1::Observed(prepared) = prepared else { - panic!("fresh observed update must prepare an observation"); - }; - let (_owner, observation) = prepared.into_parts(); - observation - } - - #[test] - fn consuming_evaluator_rejects_wrong_keyed_schema_before_composition() { - let paint = EncodedPointPaintV1::from_admitted( - PaintId::new(1), - Srgb8::new([0; 3]), - AdmittedOpacityV1::new(1.0).unwrap(), - ); - let compiled = CompiledPointSupportRecheckV1::new( - CompositionProfileV1::EncodedSrgb8SourceOverV1, - vec![PointSupportOccurrenceRequirementV1::new( - OccurrenceId::new(1), - REQUIRED_SURFACE, - paint, - Some(Srgb8::new([0; 3])), - PointSupportCriterionRequirementV1::NotRequested, - PointSupportStabilityPolicyV1::Disabled, - )], - ) - .unwrap(); - let recheck = compiled.into_session_recheck(); - - crate::composition::reset_source_over_evaluation_count(); - assert_eq!( - recheck - .evaluate( - wrong_schema_observation(), - SessionObservationBindingPermitV1::mint(), - ) - .unwrap_err(), - PointSupportEvaluationErrorV1::ObservationSchemaMismatch( - ObservationSchemaMismatchV1::new(0, 0, Some(REQUIRED_SURFACE), Some(WRONG_SURFACE),), - ) - ); - assert_eq!(crate::composition::source_over_evaluation_count(), 0); - } - - #[test] - fn consuming_evaluator_rejects_narrow_schema_without_indexing_panic() { - let paint = EncodedPointPaintV1::from_admitted( - PaintId::new(1), - Srgb8::new([0; 3]), - AdmittedOpacityV1::new(1.0).unwrap(), - ); - let compiled = CompiledPointSupportRecheckV1::new( - CompositionProfileV1::EncodedSrgb8SourceOverV1, - vec![ - PointSupportOccurrenceRequirementV1::new( - OccurrenceId::new(1), - REQUIRED_SURFACE, - paint, - Some(Srgb8::new([0; 3])), - PointSupportCriterionRequirementV1::NotRequested, - PointSupportStabilityPolicyV1::Disabled, - ), - PointSupportOccurrenceRequirementV1::new( - OccurrenceId::new(2), - WRONG_SURFACE, - paint, - Some(Srgb8::new([0; 3])), - PointSupportCriterionRequirementV1::NotRequested, - PointSupportStabilityPolicyV1::Disabled, - ), - ], - ) - .unwrap(); - let recheck = compiled.into_session_recheck(); - - crate::composition::reset_source_over_evaluation_count(); - assert_eq!( - recheck - .evaluate( - narrow_schema_observation(), - SessionObservationBindingPermitV1::mint(), - ) - .unwrap_err(), - PointSupportEvaluationErrorV1::ObservationSchemaMismatch( - ObservationSchemaMismatchV1::new(0, 1, Some(WRONG_SURFACE), None), - ) - ); - assert_eq!(crate::composition::source_over_evaluation_count(), 0); +fn take_last_verified( + state: &mut SessionState, +) -> Option { + match mem::replace(state, SessionState::Waiting) { + SessionState::Waiting => None, + SessionState::Ready { current } => Some(current), + SessionState::Stale { previous } => Some(previous), + SessionState::Failed { previous, .. } => previous, } } diff --git a/crates/labcolors-core/src/session_tests.rs b/crates/labcolors-core/src/session_tests.rs index 6574981f..18b4f834 100644 --- a/crates/labcolors-core/src/session_tests.rs +++ b/crates/labcolors-core/src/session_tests.rs @@ -1,65 +1,162 @@ -use proptest::prelude::*; +use std::cell::{Cell, RefCell}; +use std::rc::Rc; use crate::Srgb8; -use crate::appearance::{EncodedPointPaintV1, OccurrenceId, PaintId, SurfaceInputPortId}; -use crate::composition::{AdmittedOpacityV1, CompositionProfileV1}; +use crate::appearance::SurfaceInputPortId; use crate::observation::{ - ObservationError, ObservationHeadViewV1, ObservationPayloadInput, ObservationStreamId, - ObservationUpdateInput, ObservedScenarioSetInput, Revision, ScenarioId, ScenarioInput, - SurfaceInputBinding, UnknownReasonId, -}; -use crate::point_support::{ - CompiledPointSupportRecheckV1, PointSupportCriterionRequirementV1, - PointSupportOccurrenceRequirementV1, PointSupportStabilityPolicyV1, + CanonicalObservationSchemaV1, ObservationError, ObservationHeadViewV1, ObservationPayloadInput, + ObservationStreamId, ObservationUpdateInput, ObservedScenarioSetInput, Revision, + RevisionBoundObservationV1, ScenarioId, ScenarioInput, SurfaceInputBinding, UnknownReasonId, + canonicalize_observation_schema, }; use crate::session::{ - PointSupportSessionStateV1, PointSupportSessionUpdateErrorV1, PointSupportSessionV1, + Session, SessionDecision, SessionEvidenceV1, SessionObservationBindingPermitV1, SessionPlanV1, + SessionState, SessionUpdateError, private as session_private, }; -const PAINT: PaintId = PaintId::new(7); -const OCCURRENCE: OccurrenceId = OccurrenceId::new(11); -const SURFACE: SurfaceInputPortId = SurfaceInputPortId::new(21); const STREAM: ObservationStreamId = ObservationStreamId::new(31); -const TARGET: [u8; 3] = [128; 3]; +const FOREIGN_STREAM: ObservationStreamId = ObservationStreamId::new(32); +const SURFACE: SurfaceInputPortId = SurfaceInputPortId::new(21); -fn candidate() -> EncodedPointPaintV1 { - EncodedPointPaintV1::from_admitted( - PAINT, - Srgb8::new([0; 3]), - AdmittedOpacityV1::new(0.5).unwrap(), - ) +#[derive(Debug, Clone, PartialEq, Eq)] +struct SentinelVerified { + observation: RevisionBoundObservationV1, } -fn requirement() -> CompiledPointSupportRecheckV1 { - CompiledPointSupportRecheckV1::new( - CompositionProfileV1::EncodedSrgb8SourceOverV1, - vec![PointSupportOccurrenceRequirementV1::new( - OCCURRENCE, - SURFACE, - candidate(), - Some(Srgb8::new(TARGET)), - PointSupportCriterionRequirementV1::NotRequested, - PointSupportStabilityPolicyV1::Disabled, - )], - ) - .unwrap() +#[derive(Debug, Clone, PartialEq, Eq)] +struct SentinelViolation { + observation: RevisionBoundObservationV1, +} + +impl session_private::EvidenceSealed for SentinelVerified {} + +impl SessionEvidenceV1 for SentinelVerified { + fn observation(&self) -> &RevisionBoundObservationV1 { + &self.observation + } +} + +impl session_private::EvidenceSealed for SentinelViolation {} + +impl SessionEvidenceV1 for SentinelViolation { + fn observation(&self) -> &RevisionBoundObservationV1 { + &self.observation + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SentinelError { + Forced, + SchemaBackingMismatch, + EmptyObservation, +} + +#[derive(Debug, Clone)] +struct SentinelControl { + evaluations: Rc>, + fail_next: Rc>, + substitute_next: Rc>>, +} + +impl SentinelControl { + fn evaluation_count(&self) -> usize { + self.evaluations.get() + } + + fn fail_next(&self) { + self.fail_next.set(true); + } + + fn substitute_next_with(&self, observation: RevisionBoundObservationV1) { + *self.substitute_next.borrow_mut() = Some(observation); + } +} + +#[derive(Debug)] +struct SentinelPlan { + schema: CanonicalObservationSchemaV1, + control: SentinelControl, +} + +impl session_private::PlanSealed for SentinelPlan {} + +impl SessionPlanV1 for SentinelPlan { + type Verified = SentinelVerified; + type Violation = SentinelViolation; + type Error = SentinelError; + + fn observation_schema(&self) -> &CanonicalObservationSchemaV1 { + &self.schema + } + + fn evaluate( + &mut self, + observation: RevisionBoundObservationV1, + _permit: SessionObservationBindingPermitV1, + ) -> Result, Self::Error> { + self.control + .evaluations + .set(self.control.evaluations.get() + 1); + if self.control.fail_next.replace(false) { + return Err(SentinelError::Forced); + } + if !observation.shares_schema_backing_with(&self.schema) { + return Err(SentinelError::SchemaBackingMismatch); + } + let first = observation + .physical_values(0) + .and_then(|values| values.first()) + .copied() + .ok_or(SentinelError::EmptyObservation)?; + let observation = self + .control + .substitute_next + .borrow_mut() + .take() + .unwrap_or(observation); + if first == Srgb8::new([255; 3]) { + Ok(SessionDecision::Verified(SentinelVerified { observation })) + } else { + Ok(SessionDecision::Violation(SentinelViolation { + observation, + })) + } + } } -fn session() -> PointSupportSessionV1 { - PointSupportSessionV1::new(STREAM, requirement()) +fn session() -> ( + Session, + SentinelControl, + *const SurfaceInputPortId, +) { + let schema = canonicalize_observation_schema(vec![SURFACE]).unwrap(); + let schema_ptr = schema.backing_ptr_for_test(); + let control = SentinelControl { + evaluations: Rc::new(Cell::new(0)), + fail_next: Rc::new(Cell::new(false)), + substitute_next: Rc::new(RefCell::new(None)), + }; + ( + Session::new( + STREAM, + SentinelPlan { + schema, + control: control.clone(), + }, + ), + control, + schema_ptr, + ) } -fn observed_update(revision: u64, backdrop: [u8; 3]) -> ObservationUpdateInput { +fn observed_update(revision: u64, value: [u8; 3]) -> ObservationUpdateInput { ObservationUpdateInput { stream: STREAM, revision: Revision::new(revision), payload: ObservationPayloadInput::Scenarios(ObservedScenarioSetInput { scenarios: vec![ScenarioInput { id: ScenarioId::new(1), - bindings: vec![SurfaceInputBinding { - port: SURFACE, - value: Srgb8::new(backdrop), - }], + bindings: vec![SurfaceInputBinding::new(SURFACE, Srgb8::new(value))], }], }), } @@ -80,265 +177,254 @@ fn malformed_update(revision: u64) -> ObservationUpdateInput { payload: ObservationPayloadInput::Scenarios(ObservedScenarioSetInput { scenarios: vec![ScenarioInput { id: ScenarioId::new(1), - bindings: vec![], + bindings: Vec::new(), }], }), } } -fn verified_revision(state: &PointSupportSessionStateV1) -> Option { - state - .last_verified() - .map(|verified| verified.report().observation().revision()) +fn raw_observed(session: &Session) -> &RevisionBoundObservationV1 { + let ObservationHeadViewV1::Observed(observation) = session.raw_head() else { + panic!("raw head must be Observed"); + }; + observation } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum StateKind { - Waiting, - Ready, - Stale, - Failed, +fn verified_revision( + state: &SessionState, +) -> Option { + state + .last_verified() + .map(|verified| verified.observation.revision()) } -fn state_kind(state: &PointSupportSessionStateV1) -> StateKind { - match state { - PointSupportSessionStateV1::Waiting { .. } => StateKind::Waiting, - PointSupportSessionStateV1::Ready { .. } => StateKind::Ready, - PointSupportSessionStateV1::Stale { .. } => StateKind::Stale, - PointSupportSessionStateV1::Failed { .. } => StateKind::Failed, - } +fn assert_shared_observation( + raw: &RevisionBoundObservationV1, + evidence: &RevisionBoundObservationV1, +) { + assert_eq!(raw, evidence); + assert_eq!(raw.backing_ptr_for_test(), evidence.backing_ptr_for_test()); + assert_eq!(raw.schema_ptr_for_test(), evidence.schema_ptr_for_test()); } #[test] -fn construction_uses_only_the_compiled_schema_and_profile() { - let session = session(); - assert!(matches!( - session.state(), - PointSupportSessionStateV1::Waiting { - current_unknown: None, - } - )); +fn construction_is_waiting_and_owns_no_raw_evidence() { + let (session, control, _) = session(); + assert!(matches!(session.state(), SessionState::Waiting)); assert_eq!(session.raw_head(), ObservationHeadViewV1::Empty); - assert_eq!( - session.composition_profile(), - CompositionProfileV1::EncodedSrgb8SourceOverV1 - ); + assert_eq!(control.evaluation_count(), 0); } #[test] -fn ready_violation_unknown_preserves_exactly_one_verified_witness() { - let mut session = session(); - let PointSupportSessionStateV1::Ready { current } = - session.update(observed_update(1, [255; 3])).unwrap() - else { - panic!("white backdrop must verify #808080 target"); - }; - assert_eq!(current.report().observation().revision(), Revision::new(1)); - assert_eq!( - current.report().cells().next().unwrap().provenance(), - &[ScenarioId::new(1)] - ); +fn ready_failed_unknown_retains_exactly_one_verified_witness() { + let (mut session, control, schema_ptr) = session(); - let PointSupportSessionStateV1::Failed { cause, previous } = - session.update(observed_update(2, [0; 3])).unwrap() - else { - panic!("black backdrop must violate #808080 target"); + let current_observation = match session.update(observed_update(1, [255; 3])).unwrap() { + SessionState::Ready { current } => current.observation.clone(), + _ => panic!("white sentinel input must verify"), }; - assert_eq!(cause.report().observation().revision(), Revision::new(2)); - assert_eq!( - previous.as_ref().unwrap().report().observation().revision(), - Revision::new(1) - ); + assert_eq!(current_observation.revision(), Revision::new(1)); + assert_eq!(current_observation.schema_ptr_for_test(), schema_ptr); + assert_shared_observation(raw_observed(&session), ¤t_observation); + + let (cause_observation, previous_revision) = + match session.update(observed_update(2, [0; 3])).unwrap() { + SessionState::Failed { cause, previous } => ( + cause.observation.clone(), + previous.as_ref().unwrap().observation.revision(), + ), + _ => panic!("black sentinel input must violate"), + }; + assert_eq!(cause_observation.revision(), Revision::new(2)); + assert_eq!(previous_revision, Revision::new(1)); + assert_shared_observation(raw_observed(&session), &cause_observation); - let PointSupportSessionStateV1::Stale { - previous, - current_unknown, - } = session.update(unknown_update(3, 9)).unwrap() - else { - panic!("unknown after a verified result must become Stale"); + let previous_revision = match session.update(unknown_update(3, 9)).unwrap() { + SessionState::Stale { previous } => previous.observation.revision(), + _ => panic!("Unknown after a verified result must become Stale"), }; - let expected_unknown = *current_unknown; - assert_eq!(previous.report().observation().revision(), Revision::new(1)); - assert_eq!(current_unknown.stream(), STREAM); - assert_eq!(current_unknown.revision(), Revision::new(3)); - assert_eq!(current_unknown.reason(), UnknownReasonId::new(9)); - assert_eq!( - session.raw_head(), - ObservationHeadViewV1::Unknown(&expected_unknown) - ); + assert_eq!(previous_revision, Revision::new(1)); + let ObservationHeadViewV1::Unknown(unknown) = session.raw_head() else { + panic!("Unknown belongs to the separate raw head"); + }; + assert_eq!(unknown.stream(), STREAM); + assert_eq!(unknown.revision(), Revision::new(3)); + assert_eq!(unknown.reason(), UnknownReasonId::new(9)); + assert_eq!(control.evaluation_count(), 2); } #[test] -fn violation_without_prior_then_unknown_is_waiting() { - let mut session = session(); +fn violation_without_verified_then_unknown_returns_to_waiting() { + let (mut session, control, _) = session(); assert!(matches!( session.update(observed_update(1, [0; 3])).unwrap(), - PointSupportSessionStateV1::Failed { previous: None, .. } + SessionState::Failed { previous: None, .. } + )); + assert!(matches!( + session.update(unknown_update(2, 7)).unwrap(), + SessionState::Waiting )); - let PointSupportSessionStateV1::Waiting { - current_unknown: Some(current_unknown), - } = session.update(unknown_update(2, 1)).unwrap() - else { - panic!("unknown without a verified result must be retained by Waiting"); - }; - let expected_unknown = *current_unknown; - assert_eq!(current_unknown.stream(), STREAM); - assert_eq!(current_unknown.revision(), Revision::new(2)); - assert_eq!(current_unknown.reason(), UnknownReasonId::new(1)); - assert_eq!( - session.raw_head(), - ObservationHeadViewV1::Unknown(&expected_unknown) - ); assert_eq!(verified_revision(session.state()), None); + assert_eq!(control.evaluation_count(), 1); } #[test] -fn stale_and_failed_transitions_move_one_previous_without_history() { - let mut session = session(); +fn exact_replay_is_idempotent_and_never_invokes_the_plan() { + let (mut session, control, _) = session(); session.update(observed_update(1, [255; 3])).unwrap(); - session.update(unknown_update(2, 1)).unwrap(); - assert_eq!(verified_revision(session.state()), Some(Revision::new(1))); - assert_eq!(session.raw_head().revision(), Some(Revision::new(2))); + let raw_backing = raw_observed(&session).backing_ptr_for_test(); + assert_eq!(control.evaluation_count(), 1); - session.update(observed_update(3, [0; 3])).unwrap(); - assert_eq!(state_kind(session.state()), StateKind::Failed); - assert_eq!(verified_revision(session.state()), Some(Revision::new(1))); - assert_eq!(session.raw_head().revision(), Some(Revision::new(3))); + session.update(observed_update(1, [255; 3])).unwrap(); + assert_eq!(control.evaluation_count(), 1); + assert_eq!(raw_observed(&session).backing_ptr_for_test(), raw_backing); + let SessionState::Ready { current } = session.state() else { + panic!("exact replay must retain Ready"); + }; + assert_shared_observation(raw_observed(&session), ¤t.observation); - session.update(unknown_update(4, 2)).unwrap(); - assert_eq!(state_kind(session.state()), StateKind::Stale); - assert_eq!(verified_revision(session.state()), Some(Revision::new(1))); - assert_eq!(session.raw_head().revision(), Some(Revision::new(4))); + session.update(unknown_update(2, 11)).unwrap(); + session.update(unknown_update(2, 11)).unwrap(); + assert_eq!(control.evaluation_count(), 1); + assert_eq!(session.raw_head().revision(), Some(Revision::new(2))); +} + +#[test] +fn equal_content_at_a_higher_revision_rebinds_fresh_evidence() { + let (mut session, control, _) = session(); + session.update(observed_update(1, [255; 3])).unwrap(); + let first_backing = raw_observed(&session).backing_ptr_for_test(); - session.update(observed_update(5, [255; 3])).unwrap(); - assert_eq!(state_kind(session.state()), StateKind::Ready); - assert_eq!(verified_revision(session.state()), Some(Revision::new(5))); - assert_eq!(session.raw_head().revision(), Some(Revision::new(5))); + session.update(observed_update(2, [255; 3])).unwrap(); + assert_eq!(control.evaluation_count(), 2); + assert_ne!(raw_observed(&session).backing_ptr_for_test(), first_backing); + let SessionState::Ready { current } = session.state() else { + panic!("higher revision must produce fresh Ready evidence"); + }; + assert_eq!(current.observation.revision(), Revision::new(2)); + assert_shared_observation(raw_observed(&session), ¤t.observation); } #[test] -fn unknown_idempotent_and_rejected_updates_never_evaluate() { - let mut session = session(); - crate::composition::reset_source_over_evaluation_count(); - let unknown = unknown_update(1, 1); - session.update(unknown.clone()).unwrap(); - assert_eq!(crate::composition::source_over_evaluation_count(), 0); - let before = session.clone(); - session.update(unknown).unwrap(); - assert_eq!(crate::composition::source_over_evaluation_count(), 0); - assert_eq!(session, before); - - let update = observed_update(2, [255; 3]); - session.update(update.clone()).unwrap(); - assert_eq!(crate::composition::source_over_evaluation_count(), 1); - let before = session.clone(); - session.update(update).unwrap(); - assert_eq!(crate::composition::source_over_evaluation_count(), 1); - assert_eq!(session, before); - - let before = session.clone(); - crate::composition::reset_source_over_evaluation_count(); +fn rejected_admission_neither_invokes_plan_nor_mutates_closed_state() { + let (mut session, control, _) = session(); + session.update(observed_update(1, [255; 3])).unwrap(); + let raw_backing = raw_observed(&session).backing_ptr_for_test(); + + let mut foreign = observed_update(2, [0; 3]); + foreign.stream = FOREIGN_STREAM; + assert_eq!( + session.update(foreign), + Err(SessionUpdateError::Observation( + ObservationError::StreamMismatch { + expected: STREAM, + actual: FOREIGN_STREAM, + } + )) + ); + assert_eq!( + session.update(malformed_update(2)), + Err(SessionUpdateError::Observation( + ObservationError::MissingSurfaceInputBinding { + scenario: ScenarioId::new(1), + input: SURFACE, + } + )) + ); assert_eq!( - session.update(malformed_update(1)), - Err(PointSupportSessionUpdateErrorV1::Observation( + session.update(observed_update(0, [255; 3])), + Err(SessionUpdateError::Observation( ObservationError::RevisionOutOfOrder { - current: Revision::new(2), - incoming: Revision::new(1), - }, + current: Revision::new(1), + incoming: Revision::new(0), + } )) ); - assert_eq!(crate::composition::source_over_evaluation_count(), 0); - assert_eq!(session, before); - - for rejected in [ - malformed_update(3), - ObservationUpdateInput { - stream: ObservationStreamId::new(99), - revision: Revision::new(3), - payload: ObservationPayloadInput::Unknown(UnknownReasonId::new(1)), - }, - observed_update(2, [0; 3]), - ] { - let before = session.clone(); - crate::composition::reset_source_over_evaluation_count(); - assert!(session.update(rejected).is_err()); - assert_eq!(crate::composition::source_over_evaluation_count(), 0); - assert_eq!(session, before); - } + assert_eq!( + session.update(observed_update(1, [0; 3])), + Err(SessionUpdateError::Observation( + ObservationError::RevisionConflict { + revision: Revision::new(1), + } + )) + ); + + assert_eq!(control.evaluation_count(), 1); + assert_eq!(raw_observed(&session).backing_ptr_for_test(), raw_backing); + assert_eq!(verified_revision(session.state()), Some(Revision::new(1))); } #[test] -fn resource_preflight_failure_is_atomic_and_retryable() { - let mut session = session(); +fn plan_failure_commits_neither_raw_head_nor_lifecycle_and_retry_is_fresh() { + let (mut session, control, _) = session(); session.update(observed_update(1, [255; 3])).unwrap(); - let before = session.clone(); - session.force_next_resource_failure(); - crate::composition::reset_source_over_evaluation_count(); + let raw_backing = raw_observed(&session).backing_ptr_for_test(); + control.fail_next(); + + assert_eq!( + session.update(observed_update(2, [0; 3])), + Err(SessionUpdateError::Plan(SentinelError::Forced)) + ); + assert_eq!(control.evaluation_count(), 2); + assert_eq!(raw_observed(&session).backing_ptr_for_test(), raw_backing); + assert_eq!(session.raw_head().revision(), Some(Revision::new(1))); + assert_eq!(verified_revision(session.state()), Some(Revision::new(1))); + + let (cause_observation, previous_revision) = + match session.update(observed_update(2, [0; 3])).unwrap() { + SessionState::Failed { cause, previous } => ( + cause.observation.clone(), + previous.as_ref().unwrap().observation.revision(), + ), + _ => panic!("retry must re-prepare, re-evaluate and commit"), + }; + assert_eq!(control.evaluation_count(), 3); + assert_eq!(cause_observation.revision(), Revision::new(2)); + assert_eq!(previous_revision, Revision::new(1)); + assert_shared_observation(raw_observed(&session), &cause_observation); +} + +#[test] +fn detached_plan_evidence_is_rejected_before_raw_or_lifecycle_commit() { + let (mut session, control, _) = session(); + session.update(observed_update(1, [255; 3])).unwrap(); + let first_observation = raw_observed(&session).clone(); + control.substitute_next_with(first_observation); + assert_eq!( session.update(observed_update(2, [255; 3])), - Err(PointSupportSessionUpdateErrorV1::ResourceExhausted) + Err(SessionUpdateError::EvidenceBindingInvariant) ); - assert_eq!(crate::composition::source_over_evaluation_count(), 0); - assert_eq!(session, before); - assert_eq!(session.state(), before.state()); - assert_eq!(session.raw_head(), before.raw_head()); - assert_eq!(session.composition_profile(), before.composition_profile()); + assert_eq!(control.evaluation_count(), 2); + assert_eq!(session.raw_head().revision(), Some(Revision::new(1))); + assert_eq!(verified_revision(session.state()), Some(Revision::new(1))); - session.update(observed_update(2, [255; 3])).unwrap(); - assert_eq!(verified_revision(session.state()), Some(Revision::new(2))); + let current_observation = match session.update(observed_update(2, [255; 3])).unwrap() { + SessionState::Ready { current } => current.observation.clone(), + _ => panic!("a fresh retry must bind evidence from the current observation"), + }; + assert_eq!(control.evaluation_count(), 3); + assert_eq!(current_observation.revision(), Revision::new(2)); + assert_shared_observation(raw_observed(&session), ¤t_observation); } -proptest! { - #[test] - fn lifecycle_matches_pure_last_verified_model(ops in prop::collection::vec(0u8..5, 1..60)) { - let mut session = session(); - session.update(observed_update(1, [255; 3])).unwrap(); - let mut raw_revision = 1u64; - let mut expected_kind = StateKind::Ready; - let mut expected_verified = Some(Revision::new(1)); - let mut last_applied = observed_update(1, [255; 3]); - - for (next_revision, op) in (2u64..).zip(ops) { - let before = session.clone(); - match op { - 0 => { - let update = observed_update(next_revision, [255; 3]); - session.update(update.clone()).unwrap(); - raw_revision = next_revision; - expected_kind = StateKind::Ready; - expected_verified = Some(Revision::new(next_revision)); - last_applied = update; - } - 1 => { - let update = observed_update(next_revision, [0; 3]); - session.update(update.clone()).unwrap(); - raw_revision = next_revision; - expected_kind = StateKind::Failed; - last_applied = update; - } - 2 => { - let update = unknown_update(next_revision, u32::from(op)); - session.update(update.clone()).unwrap(); - raw_revision = next_revision; - expected_kind = if expected_verified.is_some() { - StateKind::Stale - } else { - StateKind::Waiting - }; - last_applied = update; - } - 3 => { - session.update(last_applied.clone()).unwrap(); - prop_assert_eq!(&session, &before); - } - _ => { - let rejected = observed_update(raw_revision, [17; 3]); - prop_assert!(session.update(rejected).is_err()); - prop_assert_eq!(&session, &before); - } - } - prop_assert_eq!(state_kind(session.state()), expected_kind); - prop_assert_eq!(verified_revision(session.state()), expected_verified); - } +#[test] +fn session_source_contains_one_generic_update_owner_and_no_legacy_runtime() { + let source = include_str!("session.rs"); + assert_eq!(source.matches("pub(crate) fn update(").count(), 1); + for forbidden in [ + "PointSupportSessionV1", + "PointSupportSessionStateV1", + "PointSupportSessionUpdateErrorV1", + "Weak<", + "ProgramExpired", + "ObservationStreamBinding", + "SurfaceUpdate", + "Box (f64, f64, f64) }) { return hit; } - #[cfg(test)] - FORWARD_CALLS.with(|c| c.set(c.get() + 1)); - let result = forward_compute(xyz, vc); + let result = forward_cache_free_v1(xyz, vc); FORWARD_CACHE.with(|c| { let mut c = c.borrow_mut(); if c.active { @@ -297,6 +295,52 @@ pub(crate) fn forward(xyz: [f64; 3], vc: &ViewingConditions) -> (f64, f64, f64) result } +/// Execute one CAM16 forward pass without consulting the legacy per-set cache. +/// +/// The legacy cache is deliberately keyed only by XYZ because its +/// `resolve_set` owner holds one viewing condition for the entire guard scope. +/// An F0 appearance state instead carries its own immutable context, so it must +/// never inherit that ambient single-context assumption. Both paths still use +/// the same numeric owner below; this boundary changes caching only, not math or +/// operation order. +#[inline] +fn forward_cache_free_v1(xyz: [f64; 3], vc: &ViewingConditions) -> (f64, f64, f64) { + #[cfg(test)] + FORWARD_CALLS.with(|c| c.set(c.get() + 1)); + forward_compute(xyz, vc) +} + +/// Complete correlates of the registered CAM16 forward view. +/// +/// This is an internal numeric carrier, not an editable colour value or a +/// difference metric. Hue absence is classified by the occurrence layer after +/// all coordinates have passed its finite-domain admission. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct Cam16CorrelatesV1 { + pub(crate) j: f64, + pub(crate) q: f64, + pub(crate) c: f64, + pub(crate) m: f64, + pub(crate) s: f64, + pub(crate) h: f64, +} + +/// Derive the full CAM16 correlate set from the same cache-free `J/M/h` +/// operation-order owner used on a cache miss by [`forward`]. +/// +/// `C`, `Q` and `s` are the published CAM16 correlates. The explicit `J = 0` +/// branch gives mathematical black `(C, M, Q, s) = 0` without evaluating the +/// otherwise indeterminate `C / sqrt(J / 100)` ratio. +pub(crate) fn forward_correlates_v1(xyz: [f64; 3], vc: &ViewingConditions) -> Cam16CorrelatesV1 { + let (j, m, h) = forward_cache_free_v1(xyz, vc); + let root_j = (j / 100.0).sqrt(); + let c = m / vc.fl_pow_025; + let q = (4.0 / vc.c) * root_j * (vc.aw + 4.0) * vc.fl_pow_025; + let alpha = if root_j == 0.0 { 0.0 } else { c / root_j }; + let s = 50.0 * (vc.c * alpha / (vc.aw + 4.0)).sqrt(); + Cam16CorrelatesV1 { j, q, c, m, s, h } +} + /// The CIECAM16 forward math itself (cache-free); see [`forward`]. fn forward_compute(xyz: [f64; 3], vc: &ViewingConditions) -> (f64, f64, f64) { let xyz = [xyz[0] * 100.0, xyz[1] * 100.0, xyz[2] * 100.0]; diff --git a/crates/labcolors-core/src/spaces/oklab.rs b/crates/labcolors-core/src/spaces/oklab.rs index 2d02a326..6f23b42d 100644 --- a/crates/labcolors-core/src/spaces/oklab.rs +++ b/crates/labcolors-core/src/spaces/oklab.rs @@ -1,4 +1,4 @@ -//! Oklab perceptual colour space (sRGB path). +//! Oklab perceptual colour-space kernels for direct XYZ(D65) and sRGB paths. //! //! Source: Björn Ottosson, "A perceptual color space for image processing" //! (2020), . The matrices below are @@ -25,6 +25,18 @@ const LMS_TO_OKLAB: [[f64; 3]; 3] = [ [ 0.0259040371, 0.7827717662, -0.8086757660], ]; +// Direct XYZ(D65) -> LMS projection used by the registered F0 Oklab view. +// These are the CSS Color 4 / 2021 Oklab coefficients. Keeping this projection +// direct is important: an XYZ stimulus is not an encoded or linear-sRGB value, +// and routing it through the inverse sRGB matrix would make an output space an +// accidental part of stimulus geometry. +#[rustfmt::skip] +const XYZ_D65_TO_LMS_OKLAB_20210125: [[f64; 3]; 3] = [ + [0.819_022_437_996_703, 0.3619062600528904, -0.1288737815209879], + [0.0329836539323885, 0.9292868615863434, 0.0361446663506424], + [0.0481771893596242, 0.2642395317527308, 0.6335478284694309], +]; + /// Canonical degree domain of a hue angle. pub(crate) const HUE_DEG_MIN_INCLUSIVE: f64 = 0.0; pub(crate) const HUE_DEG_MAX_EXCLUSIVE: f64 = 360.0; @@ -57,6 +69,18 @@ pub(crate) fn srgb_linear_to_oklab(rgb: [f64; 3]) -> [f64; 3] { mat_vec_mul(LMS_TO_OKLAB, lms_) } +/// Direct 2021 Oklab projection of one relative XYZ(D65) stimulus. +/// +/// The caller owns frame admission. This kernel does not perform chromatic +/// adaptation, infer an output profile, clamp coordinates or provide an +/// inverse/editing operation. +#[inline] +pub(crate) fn xyz_d65_to_oklab_v1(xyz: [f64; 3]) -> [f64; 3] { + let lms = mat_vec_mul(XYZ_D65_TO_LMS_OKLAB_20210125, xyz); + let lms_root = [lms[0].cbrt(), lms[1].cbrt(), lms[2].cbrt()]; + mat_vec_mul(LMS_TO_OKLAB, lms_root) +} + pub(crate) fn oklab_to_srgb_linear(lab: [f64; 3]) -> [f64; 3] { let lms_ = mat_vec_mul(OKLAB_TO_LMS, lab); let lms = [lms_[0].powi(3), lms_[1].powi(3), lms_[2].powi(3)]; diff --git a/crates/labcolors-core/src/spaces/srgb.rs b/crates/labcolors-core/src/spaces/srgb.rs index 79ad447f..c5392669 100644 --- a/crates/labcolors-core/src/spaces/srgb.rs +++ b/crates/labcolors-core/src/spaces/srgb.rs @@ -147,6 +147,17 @@ pub(crate) fn srgb_linear_from_srgb8(rgb: Srgb8) -> [f64; 3] { [decode_8bit(r), decode_8bit(g), decode_8bit(b)] } +/// Derive one CIE 1931 XYZ(D65, relative Y = 1) point from exact encoded sRGB8. +/// +/// This is the single operation-order owner for the registered finite-input +/// colourimetric transform. It is a deterministic model of a declared encoded +/// point signal; it does not claim that a renderer emitted or an +/// observer measured the resulting tristimulus. +#[inline] +pub(crate) fn xyz_d65_from_srgb8_v1(rgb: Srgb8) -> [f64; 3] { + srgb_to_xyz(srgb_linear_from_srgb8(rgb)) +} + /// Parse `#RRGGBB` → `(linear, display)` in one pass over the bytes: `linear` /// is the exact 8-bit decode (as [`srgb_from_hex`]), `display` is the /// **gamma-encoded** value WCAG measures — `[r/255, g/255, b/255]` — obtained diff --git a/crates/labcolors-core/src/spaces/vc.rs b/crates/labcolors-core/src/spaces/vc.rs index 6079c6b5..5bf159ed 100644 --- a/crates/labcolors-core/src/spaces/vc.rs +++ b/crates/labcolors-core/src/spaces/vc.rs @@ -17,6 +17,17 @@ use std::sync::OnceLock; use super::{cam16::adapt, cat16::xyz_to_cone}; +/// Closed CIECAM16 surround tuple admitted by the F0 occurrence context. +/// +/// Keeping the triplets behind variants prevents callers from independently +/// combining `F`, `c` and `N_c` into a context the release never registered. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Cam16SurroundV1 { + Average, + Dim, + Dark, +} + /// Условия просмотра для модели цветового восприятия CIECAM16. /// /// Значения по умолчанию соответствуют sRGB: D65, серый фон 20 %, среднее @@ -75,6 +86,31 @@ impl Default for ViewingConditions { } impl ViewingConditions { + /// Build CAM16 derived constants from immutable semantic context inputs. + /// + /// `background_luminance_ratio` is `Y_b / Y_w`, not a percentage. The + /// existing builder consumes percent, so the conversion remains here at + /// the legacy-kernel boundary rather than leaking into the occurrence + /// domain. Admission of the numeric inputs is owned by `AppearanceContext`. + pub(crate) fn from_semantic_inputs_v1( + adapting_luminance_cd_m2: f64, + background_luminance_ratio: f64, + surround: Cam16SurroundV1, + ) -> Self { + let (f, c, nc) = match surround { + Cam16SurroundV1::Average => (1.0, 0.69, 1.0), + Cam16SurroundV1::Dim => (0.9, 0.59, 0.9), + Cam16SurroundV1::Dark => (0.8, 0.525, 0.8), + }; + Self::build( + adapting_luminance_cd_m2, + background_luminance_ratio * 100.0, + f, + c, + nc, + ) + } + /// Коэффициент фоновой яркости CAM16: `n = Y_b / Y_w`. pub fn n(&self) -> f64 { self.n diff --git a/packages/colors/bench/wasm.json b/packages/colors/bench/wasm.json index 812941ff..ddf14019 100644 --- a/packages/colors/bench/wasm.json +++ b/packages/colors/bench/wasm.json @@ -19,13 +19,13 @@ "command": "CARGO_ENCODED_RUSTFLAGS= wasm-pack build crates/labcolors-wasm --release --target web --out-dir ../../packages/colors/pkg --out-name labcolors --locked" }, "measurement": { - "source": "github-actions-run-29875865333", + "source": "github-actions-run-29934462817", "platform": "linux-x64", - "rawBytes": 421398 + "rawBytes": 424971 }, "policy": { - "maxRawBytes": 421398, - "basis": "c8d-revision-bound-point-support", + "maxRawBytes": 424971, + "basis": "v2a-domain-safe-joint-selection", "gzip": "diagnostic-only" } } diff --git a/scripts/check-wasm-size-budget.mjs b/scripts/check-wasm-size-budget.mjs index 11b2d54d..8ceb346b 100644 --- a/scripts/check-wasm-size-budget.mjs +++ b/scripts/check-wasm-size-budget.mjs @@ -14,7 +14,7 @@ export const DEFAULT_BUDGET = resolve( "packages/colors/bench/wasm.json", ); export const WASM_BUDGET_FILE_SHA256 = - "1fc218f1ed02aabe298f43a484bebef3d046269e05eb479a145b86cfde8a7a30"; + "c51c0bd3d62bf3b1d57ea5f9b65da48f0b50621472071eaa37ff8ba145ab1bc7"; const SCHEMA_VERSION = 1; const CANONICAL_ARTIFACT = "packages/colors/pkg/labcolors_bg.wasm"; diff --git a/scripts/test_point_support_surplus_source_binding.py b/scripts/test_point_support_surplus_source_binding.py index 4949cf53..9e43f10d 100644 --- a/scripts/test_point_support_surplus_source_binding.py +++ b/scripts/test_point_support_surplus_source_binding.py @@ -55,13 +55,13 @@ def test_complete_production_dependency_cone_is_bound(self) -> None: ), ( self.observation_path, - b" &self.cases\n", - b" &[]\n", + b" self.backing.set.values(case_index)\n", + b" None\n", ), ( self.session_path, - b"ObservationHeadViewV1::Observed(current.report().observation())", - b"ObservationHeadViewV1::Empty", + b" Self::Observed(observation) => ObservationHeadViewV1::Observed(observation),\n", + b" Self::Observed(_) => ObservationHeadViewV1::Empty,\n", ), ( self.numerics_path, diff --git a/scripts/verify-package-release.mjs b/scripts/verify-package-release.mjs index d03f1c78..bc0a0186 100644 --- a/scripts/verify-package-release.mjs +++ b/scripts/verify-package-release.mjs @@ -335,7 +335,7 @@ async function validatePointSupportEvidence(artifacts, numericalCapabilities) { POINT_SUPPORT_SOURCE_BINDING_EXCLUSIONS, ) || !/^[0-9a-f]{64}$/u.test(proof.source_closure_sha256 ?? "") || - proof.source_negative_controls !== 33 || + proof.source_negative_controls !== 42 || !/^[0-9a-f]{64}$/u.test(proof.proof_payload_sha256 ?? "") || !/^[0-9a-f]{64}$/u.test(proof.verifier_sha256 ?? "") || !Array.isArray(proof.source_files) || diff --git a/scripts/verify_point_support_surplus.py b/scripts/verify_point_support_surplus.py index 682cc3da..8d88725e 100644 --- a/scripts/verify_point_support_surplus.py +++ b/scripts/verify_point_support_surplus.py @@ -57,7 +57,7 @@ SOURCE_BINDING_LAW = "point-support-rust-whole-file-semantic-cone-v2" SOURCE_BINDING_DOMAIN = b"labcolors.point-support.rust-whole-file-semantic-cone.v2" EXPECTED_SOURCE_CAPSULE_SHA256 = ( - "c2825216354b796924560d98e01ae5cebedf324c47e7b26332119c61aded783e" + "2dba7f59bd0f8d665b79d3286527cc67a99d1dfe1f7604e6d19be3643e39ed5d" ) EXPECTED_Q55_PROOF_SHA256 = ( "ac59cf89503170c789223b91d775213a19d4e571ef930f2ea609fcd51b14defd" @@ -192,17 +192,26 @@ def verify_source_binding() -> tuple[str, int]: (POINT_SOURCE, b"NumericalSiteIdV2::PointSupportRetainedReferenceSurplusV1;", b"NumericalSiteIdV2::Wcag22Srgb8ContrastV1;"), (POINT_SOURCE, b"let current_distance = reference_distance(current_measurement)?;", b"let current_distance = baseline.distance;"), (POINT_SOURCE, b"Ok(assessment.bind(observation))", b"Ok(assessment.bind_unchecked(observation))"), + (POINT_SOURCE, b" let backdrop = values.get(*surface_index).copied().ok_or(\n", b" let backdrop = values.first().copied().ok_or(\n"), + (POINT_SOURCE, b" if !observation.shares_schema_backing_with(&plan.surface_schema) {\n", b" if observation.shares_schema_backing_with(&plan.surface_schema) {\n"), + (POINT_SOURCE, b" _permit: SessionObservationBindingPermitV1,\n", b" _permit: (),\n"), (POINT_SOURCE, b"use crate::wcag22::{Wcag22CriterionV1, Wcag22MeasurementV1, measure_wcag22_srgb8};", b"use crate::wcag22::{Wcag22CriterionV1, Wcag22MeasurementV1, measure_wcag22_srgb8 as canonical_measure_wcag22_srgb8};\nfn measure_wcag22_srgb8(foreground: [u8; 3], background: [u8; 3]) -> Wcag22MeasurementV1 { canonical_measure_wcag22_srgb8(background, foreground) }"), - (OBSERVATION_SOURCE, b" &self.cases\n", b" &[]\n"), + (OBSERVATION_SOURCE, b" self.backing.set.values(case_index)\n", b" None\n"), + (OBSERVATION_SOURCE, b" values.extend(bindings.iter().map(|binding| binding.value));\n", b" values.extend(bindings.iter().map(|_| Srgb8::new([0, 0, 0])));\n"), + (OBSERVATION_SOURCE, b" Rc::ptr_eq(&self.0, &other.0)\n", b" self == other\n"), + (OBSERVATION_SOURCE, b" schema: schema.clone(),\n", b" schema: CanonicalObservationSchemaV1(Rc::from(schema.as_slice())),\n"), (OBSERVATION_SOURCE, b"if expected_input != actual_input", b"if expected_input == actual_input"), (OBSERVATION_SOURCE, b"Some(observation.revision)", b"None"), (OBSERVATION_SOURCE, b"(self.owner, self.observation)", b"unreachable!()"), - (OBSERVATION_SOURCE, b"tuples.push((scenario.bindings, scenario.id));", b"tuples.push((Vec::new(), scenario.id));"), - (SESSION_SOURCE, b"ObservationHeadViewV1::Observed(current.report().observation())", b"ObservationHeadViewV1::Empty"), - (SESSION_SOURCE, b"recheck: compiled.into_session_recheck(),", b"recheck: unreachable!(),"), - (SESSION_SOURCE, b".evaluate(observation, SessionObservationBindingPermitV1::mint())", b".evaluate(observation, SessionObservationBindingPermitV1::bypass())"), - (SESSION_SOURCE, b"PointSupportEvaluationErrorV1::ResourceExhausted => {\n PointSupportSessionUpdateErrorV1::ResourceExhausted", b"PointSupportEvaluationErrorV1::ResourceExhausted => {\n PointSupportSessionUpdateErrorV1::InternalInvariant"), - (SESSION_SOURCE, b"PointSupportSessionStateV1::Ready { current } => Some(current),", b"PointSupportSessionStateV1::Ready { .. } => None,"), + (OBSERVATION_SOURCE, b" && Rc::ptr_eq(&self.backing, &other.backing)\n", b" && self.backing == other.backing\n"), + (SESSION_SOURCE, b" Self::Observed(observation) => ObservationHeadViewV1::Observed(observation),\n", b" Self::Observed(_) => ObservationHeadViewV1::Empty,\n"), + (SESSION_SOURCE, b" let next_raw_head = SessionObservationHeadV1::Observed(observation.clone());\n", b" let next_raw_head = SessionObservationHeadV1::Empty;\n"), + (SESSION_SOURCE, b" *raw_head = next_raw_head;\n", b" *raw_head = SessionObservationHeadV1::Empty;\n"), + (SESSION_SOURCE, b" Some(previous) => SessionState::Stale { previous },", b" Some(_) => SessionState::Waiting,"), + (SESSION_SOURCE, b" .evaluate(observation, SessionObservationBindingPermitV1::mint())", b" .evaluate(observation, SessionObservationBindingPermitV1::for_test())"), + (SESSION_SOURCE, b" SessionDecision::Verified(current) => SessionState::Ready { current },", b" SessionDecision::Verified(current) => SessionState::Stale { previous: current },"), + (SESSION_SOURCE, b" SessionDecision::Violation(cause) => SessionState::Failed { cause, previous },", b" SessionDecision::Violation(_) => SessionState::Waiting,"), + (SESSION_SOURCE, b" return Err(SessionUpdateError::EvidenceBindingInvariant);\n", b" unreachable!();\n"), (NUMERICS_SOURCE, b"proof_ids: [NumericalProofIdV2::PointSupportReferenceSurplusIntegerV1],\n bound_status: Available", b"proof_ids: [NumericalProofIdV2::PointSupportReferenceSurplusIntegerV1],\n bound_status: Unavailable"), (COMPOSITION_SOURCE, b"f64::from(backdrop) + alpha * (f64::from(tint) - f64::from(backdrop))", b"f64::from(tint)"), (APPEARANCE_SOURCE, b"self.opacity\n", b"crate::composition::AdmittedOpacityV1::OPAQUE\n"),