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 9445d79b..6cd37887 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":"2ab90713f2008fab2ba343d4525164ba9177d5bdda1a890bd3192a5ffd1891d8","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":"4310a239e732f0710fd6201c2517fd98ef4afd0e3cd8e27c248dee69d6c54cb3","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":"455f94bdc0064765214e21ec38e49939e9ebbf765d18bb709512f7210c986953"},{"kind":"rust-source","path":"crates/labcolors-core/src/composition.rs","sha256":"195a67327a3bd86d7816b634481389930bf68577bb1202fad14c2ea152df8625"},{"kind":"rust-source","path":"crates/labcolors-core/src/constraints/exact.rs","sha256":"33b959f11366415143b5b03fcfe370d1fb7e61e46ed05349ed17560d10663ff7"},{"kind":"rust-source","path":"crates/labcolors-core/src/constraints/mod.rs","sha256":"37cff33755c5a700853ccbc08bd539d2235325b9eb208be696444799d4cf819e"},{"kind":"rust-source","path":"crates/labcolors-core/src/constraints/wcag22.rs","sha256":"7e10638e8da68dc1279f078e0a0daa5caf10af403eb00c1cbbd5506190e74d9d"},{"kind":"rust-source","path":"crates/labcolors-core/src/hash.rs","sha256":"f97a0fd7d6ad3162f0f1dfb326fccfb7ed40da9a8fa67a5b8a239a1ae2ae49c3"},{"kind":"rust-source","path":"crates/labcolors-core/src/lcs_occurrence.rs","sha256":"9ad998d3b7ac01a03afa398a6750ab71dc1278da991202933cf9006c3cedf5f5"},{"kind":"rust-source","path":"crates/labcolors-core/src/lib.rs","sha256":"cb52f917d260ee580cd5cb78666ed5d3f1fe9351e9874b51250d1e8571a48c73"},{"kind":"rust-source","path":"crates/labcolors-core/src/numerics.rs","sha256":"e73a12136494f2ef9aca4e943ab38302c1439f054cecab36a552d35252c164f9"},{"kind":"rust-source","path":"crates/labcolors-core/src/observation.rs","sha256":"f1d6c7a66885326caea2f7f469061c723b826ff99b294324e5a478724f8981f6"},{"kind":"rust-source","path":"crates/labcolors-core/src/point_support.rs","sha256":"0755210e3e591d7049f293a0f0b7647681631f32feee5ad7d3b3309cffca8f9d"},{"kind":"rust-source","path":"crates/labcolors-core/src/session.rs","sha256":"6cd73600267b148c3e9ecc8d2d623f7f8576aed0e1c4c7c50071e297810b8d4b"},{"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":43,"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":"742503a19220d71dc5d4c4ff22c9e5e7cb407d2506e450312b87e7ba342761f0"} +{"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":"78669986ea1a7a75f40e76c19beba4ff9c72abcbf1a240d4437846fc8d28519c","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":"246c77dbf4923aca4cdaedb12be910ceb2885c94e4e42f652cdcba9b9417a427","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":"455f94bdc0064765214e21ec38e49939e9ebbf765d18bb709512f7210c986953"},{"kind":"rust-source","path":"crates/labcolors-core/src/composition.rs","sha256":"195a67327a3bd86d7816b634481389930bf68577bb1202fad14c2ea152df8625"},{"kind":"rust-source","path":"crates/labcolors-core/src/constraints/exact.rs","sha256":"892576a8621185352583e63dc0a1aacac32e32a8063b6fe24ae16d4ff9dce7cb"},{"kind":"rust-source","path":"crates/labcolors-core/src/constraints/mod.rs","sha256":"3a4c2911781f91c91c12fc23929843865a8e41d5630f4df41f77130434b5f228"},{"kind":"rust-source","path":"crates/labcolors-core/src/constraints/wcag22.rs","sha256":"40f288b222fbd102970916437d3f99c33ec2dfb77041b1c8361d27f08ff018ca"},{"kind":"rust-source","path":"crates/labcolors-core/src/hash.rs","sha256":"f97a0fd7d6ad3162f0f1dfb326fccfb7ed40da9a8fa67a5b8a239a1ae2ae49c3"},{"kind":"rust-source","path":"crates/labcolors-core/src/lcs_occurrence.rs","sha256":"9ad998d3b7ac01a03afa398a6750ab71dc1278da991202933cf9006c3cedf5f5"},{"kind":"rust-source","path":"crates/labcolors-core/src/lib.rs","sha256":"b3edb3764119c0b4fd50f52b62cbc07fa81c4bfdf1246b91f20eb3d8d4eebd31"},{"kind":"rust-source","path":"crates/labcolors-core/src/numerics.rs","sha256":"e73a12136494f2ef9aca4e943ab38302c1439f054cecab36a552d35252c164f9"},{"kind":"rust-source","path":"crates/labcolors-core/src/observation.rs","sha256":"f1d6c7a66885326caea2f7f469061c723b826ff99b294324e5a478724f8981f6"},{"kind":"rust-source","path":"crates/labcolors-core/src/point_support.rs","sha256":"0755210e3e591d7049f293a0f0b7647681631f32feee5ad7d3b3309cffca8f9d"},{"kind":"rust-source","path":"crates/labcolors-core/src/session.rs","sha256":"6cd73600267b148c3e9ecc8d2d623f7f8576aed0e1c4c7c50071e297810b8d4b"},{"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":43,"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":"3c067cb4ce2af0d9ff514ba0937650ebd62514bb8d6bafb7fc1bc319e0e1816a"} diff --git a/crates/labcolors-core/src/constraints/exact.rs b/crates/labcolors-core/src/constraints/exact.rs index 34fed4fb..fa5f458d 100644 --- a/crates/labcolors-core/src/constraints/exact.rs +++ b/crates/labcolors-core/src/constraints/exact.rs @@ -1,7 +1,8 @@ use crate::Srgb8; use crate::appearance::ModeledSrgb8PointOccurrence; use crate::constraints::{ - Evaluator, HardClassifier, HardDecision, ProgramPointTargetV1, VisiblePointPassEvidence, + Evaluator, HardClassifier, HardDecision, ProgramConstraintContentV1, + ProgramPointEvaluatorContentV1, ProgramPointTargetV1, VisiblePointPassEvidence, VisiblePointViolationEvidence, private, }; use core::convert::Infallible; @@ -12,12 +13,16 @@ use core::convert::Infallible; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ExactConstraintIdentityV1 { FinalSrgb8IdentityV1, + #[cfg(test)] + MutationSentinelV1, } /// Версия формулы exact byte-identity evaluator-а. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ExactIdentityReleaseV1 { V1, + #[cfg(test)] + MutationSentinelV1, } /// Узкая capability evaluator-а: только финальный modeled point occurrence в @@ -25,6 +30,8 @@ pub(crate) enum ExactIdentityReleaseV1 { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ExactIdentityCapabilityV1 { FinalOccurrenceSrgb8IdentityV1, + #[cfg(test)] + MutationSentinelV1, } /// Закрытые ZST payload-типы делают Pass и Violation несовместимыми, но не @@ -111,6 +118,17 @@ impl Evaluator for ExactSrgb8IdentityV1 { } } +impl ProgramPointEvaluatorContentV1 for ExactSrgb8IdentityV1 { + fn program_constraint_content_v1(&self, invocation: Srgb8) -> ProgramConstraintContentV1 { + ProgramConstraintContentV1::ExactSrgb8 { + identity: >::identity(self), + release: >::release(self), + capability: >::capability(self), + expected: invocation, + } + } +} + impl HardClassifier for ExactSrgb8IdentityV1 { type Pass = ExactIdentityPassV1; type Violation = ExactIdentityViolationV1; diff --git a/crates/labcolors-core/src/constraints/mod.rs b/crates/labcolors-core/src/constraints/mod.rs index fc56ab48..4aae4880 100644 --- a/crates/labcolors-core/src/constraints/mod.rs +++ b/crates/labcolors-core/src/constraints/mod.rs @@ -8,6 +8,7 @@ use crate::Srgb8; use crate::appearance::{ModeledSrgb8PointOccurrence, ResolvedOccurrence, VisiblePointBindingV1}; use crate::lcs_occurrence::ModeledLcsOccurrenceV1; +use crate::wcag22::{Wcag22CriterionV1, Wcag22ProfileIdV1}; mod exact; pub(crate) use exact::{ @@ -20,7 +21,7 @@ pub(crate) use exact::ExactIdentityPassV1; mod wcag22; -pub(crate) use wcag22::Wcag22Srgb8V1; +pub(crate) use wcag22::{Wcag22Srgb8CapabilityV1, Wcag22Srgb8EvaluatorIdentityV1, Wcag22Srgb8V1}; #[cfg(test)] pub(crate) use wcag22::{ @@ -266,6 +267,7 @@ pub(crate) trait ProgramPointEvaluatorV1: Sized + Evaluator + HardClassifier, ProgramPointMeasurement> + + ProgramPointEvaluatorContentV1 { } @@ -273,9 +275,44 @@ impl ProgramPointEvaluatorV1 for Evaluation where Evaluation: Sized + Evaluator + HardClassifier, ProgramPointMeasurement> + + ProgramPointEvaluatorContentV1 { } +/// Полное code-owned описание одного evaluator invocation для compile identity. +/// +/// Здесь намеренно нет авторского constraint ID. Метаданные берутся из того же +/// закрытого определения evaluator-а, которое связывает runtime evidence, и не +/// могут разойтись с его identity, release или capability. Добавление либо +/// изменение production evaluator-а остаётся явной сменой схемы. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ProgramConstraintContentV1 { + ExactSrgb8 { + identity: ExactConstraintIdentityV1, + release: ExactIdentityReleaseV1, + capability: ExactIdentityCapabilityV1, + expected: Srgb8, + }, + Wcag22Srgb8 { + identity: Wcag22Srgb8EvaluatorIdentityV1, + release: Wcag22ProfileIdV1, + capability: Wcag22Srgb8CapabilityV1, + criterion: Wcag22CriterionV1, + }, + #[cfg(test)] + FinalRecheckMutantExactSrgb8 { expected: Srgb8 }, +} + +/// Внутрикрейтное описание generic test seam с одним evaluator-ом. Package +/// Program использует закрытое heterogeneous-множество, поэтому клиент не +/// может подменить descriptor. +pub(crate) trait ProgramPointEvaluatorContentV1: Evaluator { + fn program_constraint_content_v1( + &self, + invocation: ProgramPointInvocation, + ) -> ProgramConstraintContentV1; +} + #[cfg(test)] impl Evaluator for CountingProgramWcag22Srgb8V1 { type Invocation = >::Invocation; @@ -313,6 +350,21 @@ impl Evaluator for CountingProgramWcag22Srgb8V1 { } } +#[cfg(test)] +impl ProgramPointEvaluatorContentV1 for CountingProgramWcag22Srgb8V1 { + fn program_constraint_content_v1( + &self, + invocation: ProgramPointInvocation, + ) -> ProgramConstraintContentV1 { + ProgramConstraintContentV1::Wcag22Srgb8 { + identity: self.identity(), + release: self.release(), + capability: self.capability(), + criterion: invocation, + } + } +} + #[cfg(test)] impl HardClassifier< @@ -375,6 +427,18 @@ impl Evaluator for FinalRecheckMutantProgramEvaluatorV1 { } } +#[cfg(test)] +impl ProgramPointEvaluatorContentV1 for FinalRecheckMutantProgramEvaluatorV1 { + fn program_constraint_content_v1( + &self, + invocation: ProgramPointInvocation, + ) -> ProgramConstraintContentV1 { + ProgramConstraintContentV1::FinalRecheckMutantExactSrgb8 { + expected: invocation, + } + } +} + #[cfg(test)] impl HardClassifier for FinalRecheckMutantProgramEvaluatorV1 { type Pass = MutantExactPassV1; diff --git a/crates/labcolors-core/src/constraints/wcag22.rs b/crates/labcolors-core/src/constraints/wcag22.rs index d57d854a..c4b28a87 100644 --- a/crates/labcolors-core/src/constraints/wcag22.rs +++ b/crates/labcolors-core/src/constraints/wcag22.rs @@ -1,5 +1,8 @@ use crate::appearance::ModeledSrgb8PointOccurrence; -use crate::constraints::{Evaluator, HardClassifier, HardDecision, ProgramPointTargetV1, private}; +use crate::constraints::{ + Evaluator, HardClassifier, HardDecision, ProgramConstraintContentV1, + ProgramPointEvaluatorContentV1, ProgramPointTargetV1, private, +}; use crate::numerics::NumericalDecisionEvidenceV1; use crate::wcag22::{ Wcag22ApplicableDecisionV1, Wcag22AssessmentV1, Wcag22ClientDeclaredNotApplicableV1, @@ -175,6 +178,20 @@ impl Evaluator for Wcag22Srgb8V1 { } } +impl ProgramPointEvaluatorContentV1 for Wcag22Srgb8V1 { + fn program_constraint_content_v1( + &self, + invocation: Wcag22CriterionV1, + ) -> ProgramConstraintContentV1 { + ProgramConstraintContentV1::Wcag22Srgb8 { + identity: >::identity(self), + release: >::release(self), + capability: >::capability(self), + criterion: invocation, + } + } +} + impl HardClassifier for Wcag22Srgb8V1 { type Pass = Wcag22PassV1; type Violation = Wcag22ViolationV1; diff --git a/crates/labcolors-core/src/generic_boundary_tests.rs b/crates/labcolors-core/src/generic_boundary_tests.rs index 53457a93..f563773f 100644 --- a/crates/labcolors-core/src/generic_boundary_tests.rs +++ b/crates/labcolors-core/src/generic_boundary_tests.rs @@ -8,13 +8,15 @@ const OBSERVATION_SOURCE: &str = include_str!("observation.rs"); const OUTPUT_PROJECTION_SOURCE: &str = include_str!("output_projection.rs"); const PACKAGE_BRIDGE_SOURCE: &str = include_str!("package_bridge.rs"); const POINT_SUPPORT_SOURCE: &str = include_str!("point_support.rs"); +const PROGRAM_IDENTITY_SOURCE: &str = include_str!("program_identity.rs"); const PROGRAM_SESSION_SOURCE: &str = include_str!("program_session.rs"); const SESSION_SOURCE: &str = include_str!("session.rs"); const WCAG22_CONSTRAINT_SOURCE: &str = include_str!("constraints/wcag22.rs"); -const GENERIC_SOURCES: [(&str, &str); 3] = [ +const GENERIC_SOURCES: [(&str, &str); 4] = [ ("appearance.rs", APPEARANCE_SOURCE), ("lcs_occurrence.rs", LCS_OCCURRENCE_SOURCE), + ("program_identity.rs", PROGRAM_IDENTITY_SOURCE), ("program_session.rs", PROGRAM_SESSION_SOURCE), ]; @@ -541,7 +543,7 @@ fn program_session_owns_context_bound_lcs_evidence_and_one_session_scratch_cache "let outputs = compile_outputs(", ); for required in [ - "compile_constraints::(&graph, &all_occurrence_contexts, program.constraints)?", + "compile_constraints::(&graph, &all_occurrence_contexts, &program.constraints)?", "compact_constraint_contexts(&all_occurrence_contexts, &mut constraints)?", ] { assert!( @@ -587,6 +589,36 @@ fn program_session_owns_context_bound_lcs_evidence_and_one_session_scratch_cache } } +#[test] +fn cold_program_normalization_reuses_owned_unordered_buffers() { + let compiler = PROGRAM_SESSION_SOURCE + .split_whitespace() + .collect::>() + .join(" "); + for required in [ + "authored_targets: &mut [Target]", + "authored_selection: Option<&mut DeclaredJointSelectionV1>", + "let TargetDomainV1::Finite(candidates) = &mut target.domain", + "authored_state .choices .sort_unstable_by_key", + "authored: &mut [OutputBinding]", + ] { + assert!( + compiler.contains(required), + "cold Program compilation must normalize owned buffers in place; missing `{required}`", + ); + } + for forbidden in [ + "candidates.extend_from_slice(authored_candidates)", + "choices.extend_from_slice(&authored_state.choices)", + "authored.extend_from_slice(authored_outputs)", + ] { + assert!( + !compiler.contains(forbidden), + "cold Program compilation must not restore avoidable shadow copy `{forbidden}`", + ); + } +} + #[test] fn program_lcs_boundary_has_no_legacy_color_or_projection_shortcuts() { for forbidden in [ diff --git a/crates/labcolors-core/src/lib.rs b/crates/labcolors-core/src/lib.rs index ec0b63ea..fa185ec4 100644 --- a/crates/labcolors-core/src/lib.rs +++ b/crates/labcolors-core/src/lib.rs @@ -64,6 +64,7 @@ pub(crate) mod program_session; pub(crate) mod release_registry; pub mod scale; pub mod semantic; +pub(crate) mod sha256; pub mod solve; pub(crate) mod wcag; @@ -102,6 +103,9 @@ mod program_joint_integration_tests; #[cfg(test)] mod program_mixed_evaluator_tests; +#[cfg(test)] +mod program_identity_tests; + #[cfg(test)] mod release_registry_tests; diff --git a/crates/labcolors-core/src/program_identity.rs b/crates/labcolors-core/src/program_identity.rs new file mode 100644 index 00000000..0c29d56c --- /dev/null +++ b/crates/labcolors-core/src/program_identity.rs @@ -0,0 +1,1732 @@ +//! Устойчивый к коллизиям адрес исполняемого содержимого принятой Program. +//! +//! Paint/Surface/Occurrence способны образовывать двудольные графы +//! инцидентности, поэтому одного топологического хеша или уточнения разбиения +//! недостаточно. Модуль строит типизированный цветной граф, канонизирует его без +//! opaque ID и хеширует канонический прообраз. + +use super::*; + +const DOMAIN_V1: &[u8] = b"labcolors.program-content-identity.v1\0"; +// Максимальный V1-цвет принадлежит Occurrence: теги вершины, композиции, +// контекста и frame, два binary64-параметра наблюдения и surround. Явная +// граница устраняет аллокацию на каждую вершину и требует пересмотра при +// расширении схемы вместо скрытого runtime-лимита. +const COLOR_CAPACITY: usize = 1 + 1 + 1 + 4 + 8 + 8 + 1; + +mod release_tag { + pub(super) const PROGRAM_SCHEMA_V1: u8 = 1; + pub(super) const DECLARED_TOTAL_ORDER_V1: u8 = 1; + pub(super) const FRESH_FULL_RECHECK_V1: u8 = 1; + pub(super) const ATOMIC_OBSERVATION_GROUP_V1: u8 = 1; + pub(super) const ENCODED_PAINT_EMISSION_V1: u8 = 1; + pub(super) const MODELED_LCS_OCCURRENCE_V1: u8 = 1; + + pub(super) const IEC_SRGB8_D65_OUTPUT_PROFILE_V1: u8 = 1; + pub(super) const IEC_SRGB8_TO_XYZ_D65_TRANSFORM_V1: u8 = 1; + pub(super) const CIE1931_TWO_DEGREE_OBSERVER_V1: u8 = 1; + pub(super) const IEC61966_D65_REFERENCE_WHITE_V1: u8 = 1; + pub(super) const RELATIVE_Y1_SCALE_V1: u8 = 1; + pub(super) const XYZ_FRAME_V1: u8 = 1; + #[cfg(test)] + pub(super) const MUTATION_SENTINEL_FRAME_V1: u8 = 2; + pub(super) const CIECAM16_VIEWING_INPUTS_V1: u8 = 1; + pub(super) const ENCODED_SRGB8_SOURCE_OVER_V1: u8 = 1; + pub(super) const SURROUND_AVERAGE_V1: u8 = 1; + pub(super) const SURROUND_DIM_V1: u8 = 2; + pub(super) const SURROUND_DARK_V1: u8 = 3; + + pub(super) const EXACT_SRGB8_FAMILY_V1: u8 = 1; + pub(super) const EXACT_SRGB8_IDENTITY_V1: u8 = 1; + pub(super) const EXACT_SRGB8_RELEASE_V1: u8 = 1; + pub(super) const EXACT_SRGB8_CAPABILITY_V1: u8 = 1; + #[cfg(test)] + pub(super) const EXACT_SRGB8_IDENTITY_MUTATION_SENTINEL_V1: u8 = 2; + #[cfg(test)] + pub(super) const EXACT_SRGB8_RELEASE_MUTATION_SENTINEL_V1: u8 = 2; + #[cfg(test)] + pub(super) const EXACT_SRGB8_CAPABILITY_MUTATION_SENTINEL_V1: u8 = 2; + pub(super) const WCAG22_SRGB8_FAMILY_V1: u8 = 2; + pub(super) const WCAG22_SRGB8_IDENTITY_V1: u8 = 1; + pub(super) const WCAG22_SRGB8_PROFILE_V1: u8 = 1; + pub(super) const WCAG22_SRGB8_CAPABILITY_V1: u8 = 1; + pub(super) const WCAG22_SC_1_4_3_TEXT_DEFAULT: u8 = 1; + pub(super) const WCAG22_SC_1_4_3_TEXT_LARGE_SCALE: u8 = 2; + pub(super) const WCAG22_SC_1_4_11_UI_COMPONENT_OR_STATE: u8 = 3; + pub(super) const WCAG22_SC_1_4_11_GRAPHICAL_OBJECT: u8 = 4; +} + +/// Устойчивый к коллизиям адрес канонизированного содержимого Program V1. +/// +/// SHA-256 не делает адрес инъективным. Адрес не связывает пространства opaque +/// ID и не подтверждает владельца, поколение либо revision. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) struct ProgramContentIdentityV1([u8; 32]); + +impl ProgramContentIdentityV1 { + pub(crate) const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +struct VertexColorV1 { + len: u8, + bytes: [u8; COLOR_CAPACITY], +} + +impl VertexColorV1 { + fn new(tag: u8) -> Self { + let mut value = Self { + len: 1, + bytes: [0; COLOR_CAPACITY], + }; + value.bytes[0] = tag; + value + } + + fn push_u8(&mut self, value: u8) -> Result<(), ProgramCompileError> { + let index = usize::from(self.len); + let slot = self + .bytes + .get_mut(index) + .ok_or(ProgramCompileError::InternalInvariant)?; + *slot = value; + self.len = self + .len + .checked_add(1) + .ok_or(ProgramCompileError::InternalInvariant)?; + Ok(()) + } + + fn push_u64(&mut self, value: u64) -> Result<(), ProgramCompileError> { + for byte in value.to_be_bytes() { + self.push_u8(byte)?; + } + Ok(()) + } + + fn push_srgb8(&mut self, value: Srgb8) -> Result<(), ProgramCompileError> { + for byte in value.bytes() { + self.push_u8(byte)?; + } + Ok(()) + } + + fn as_slice(&self) -> &[u8] { + &self.bytes[..usize::from(self.len)] + } +} + +mod vertex_tag { + pub(super) const PROGRAM: u8 = 1; + pub(super) const SOURCE: u8 = 2; + pub(super) const TARGET_FIXED: u8 = 3; + pub(super) const TARGET_FINITE: u8 = 4; + pub(super) const CANDIDATE: u8 = 5; + pub(super) const OPACITY: u8 = 6; + pub(super) const PAINT_SOLID: u8 = 7; + pub(super) const PAINT_OPACITY: u8 = 8; + pub(super) const OBSERVATION_GROUP: u8 = 9; + pub(super) const SURFACE_INPUT_PORT: u8 = 10; + pub(super) const SURFACE_INPUT: u8 = 11; + pub(super) const SURFACE_FROM_OCCURRENCE: u8 = 12; + pub(super) const OCCURRENCE: u8 = 13; + pub(super) const CONSTRAINT_HARD: u8 = 14; + pub(super) const CONSTRAINT_REPORT_ONLY: u8 = 15; + pub(super) const OUTPUT: u8 = 16; + pub(super) const JOINT_SELECTION: u8 = 17; + pub(super) const JOINT_STATE: u8 = 18; + pub(super) const JOINT_CHOICE: u8 = 19; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[repr(u8)] +enum EdgeRoleV1 { + ProgramMember = 1, + TargetSource = 2, + TargetCandidate = 3, + SolidTarget = 4, + OpacitySourcePaint = 5, + OpacityInput = 6, + ObservationGroupPort = 7, + InputSurfacePort = 8, + DerivedSurfaceOccurrence = 9, + OccurrenceSubjectPaint = 10, + OccurrenceBackdropSurface = 11, + ConstraintOccurrence = 12, + OutputPaint = 13, + SelectionState = 14, + StateChoice = 15, + ChoiceTarget = 16, + ChoiceCandidate = 17, +} + +#[derive(Debug, Clone, Copy)] +struct EdgeV1 { + from: usize, + to: usize, + role: EdgeRoleV1, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +struct ArcV1 { + direction: u8, + role: EdgeRoleV1, + neighbour: usize, +} + +struct CanonicalGraphV1 { + colors: Vec, + adjacency: Vec>, + edge_count: usize, +} + +struct GraphBuilderV1 { + colors: Vec, + edges: Vec, + root: usize, +} + +impl GraphBuilderV1 { + fn new(root: VertexColorV1) -> Result { + let mut colors = Vec::new(); + colors + .try_reserve_exact(1) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + colors.push(root); + Ok(Self { + colors, + edges: Vec::new(), + root: 0, + }) + } + + fn add_member(&mut self, color: VertexColorV1) -> Result { + self.colors + .try_reserve(1) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + let index = self.colors.len(); + self.colors.push(color); + self.add_edge(self.root, index, EdgeRoleV1::ProgramMember)?; + Ok(index) + } + + fn add_edge( + &mut self, + from: usize, + to: usize, + role: EdgeRoleV1, + ) -> Result<(), ProgramCompileError> { + if from >= self.colors.len() || to >= self.colors.len() { + return Err(ProgramCompileError::InternalInvariant); + } + self.edges + .try_reserve(1) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + self.edges.push(EdgeV1 { from, to, role }); + Ok(()) + } + + fn finish(self) -> Result { + let mut degrees = Vec::new(); + degrees + .try_reserve_exact(self.colors.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + degrees.resize(self.colors.len(), 0_usize); + for edge in &self.edges { + degrees[edge.from] = degrees[edge.from] + .checked_add(1) + .ok_or(ProgramCompileError::ResourceExhausted)?; + degrees[edge.to] = degrees[edge.to] + .checked_add(1) + .ok_or(ProgramCompileError::ResourceExhausted)?; + } + + let mut adjacency = Vec::new(); + adjacency + .try_reserve_exact(self.colors.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + for degree in degrees { + let mut arcs = Vec::new(); + arcs.try_reserve_exact(degree) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + adjacency.push(arcs); + } + for edge in &self.edges { + adjacency[edge.from].push(ArcV1 { + direction: 0, + role: edge.role, + neighbour: edge.to, + }); + adjacency[edge.to].push(ArcV1 { + direction: 1, + role: edge.role, + neighbour: edge.from, + }); + } + for arcs in &mut adjacency { + arcs.sort_unstable(); + } + Ok(CanonicalGraphV1 { + colors: self.colors, + adjacency, + edge_count: self.edges.len(), + }) + } +} + +struct IdIndexV1 { + values: Vec<(Key, usize)>, +} + +impl IdIndexV1 +where + Key: Copy + Ord, +{ + fn new() -> Self { + Self { values: Vec::new() } + } + + fn insert(&mut self, key: Key, value: usize) -> Result<(), ProgramCompileError> { + self.values + .try_reserve(1) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + self.values.push((key, value)); + Ok(()) + } + + fn finish(&mut self) -> Result<(), ProgramCompileError> { + self.values.sort_unstable_by_key(|(key, _)| *key); + if self.values.windows(2).any(|pair| pair[0].0 == pair[1].0) { + return Err(ProgramCompileError::InternalInvariant); + } + Ok(()) + } + + fn get(&self, key: Key) -> Result { + let index = self + .values + .binary_search_by_key(&key, |(candidate, _)| *candidate) + .map_err(|_| ProgramCompileError::InternalInvariant)?; + Ok(self.values[index].1) + } +} + +fn program_root_color() -> Result { + let mut color = VertexColorV1::new(vertex_tag::PROGRAM); + // Эти теги связывают адрес с версиями исполняемых законов: схемой Program, + // total-order selection, финальной перепроверкой, атомарным наблюдением, + // encoded Paint emission и формированием modeled LCS. + for release in [ + release_tag::PROGRAM_SCHEMA_V1, + release_tag::DECLARED_TOTAL_ORDER_V1, + release_tag::FRESH_FULL_RECHECK_V1, + release_tag::ATOMIC_OBSERVATION_GROUP_V1, + release_tag::ENCODED_PAINT_EMISSION_V1, + release_tag::MODELED_LCS_OCCURRENCE_V1, + ] { + color.push_u8(release)?; + } + Ok(color) +} + +fn write_signal(color: &mut VertexColorV1, signal: ColorSignal) -> Result<(), ProgramCompileError> { + let profile = match signal.output_profile() { + crate::lcs_occurrence::OutputProfileId::Iec61966Srgb8D65V1 => { + release_tag::IEC_SRGB8_D65_OUTPUT_PROFILE_V1 + } + }; + color.push_u8(profile)?; + color.push_u8(match crate::lcs_occurrence::ADMITTED_SRGB8_TRISTIMULUS_BINDING_V1 + .transform_release() + { + crate::lcs_occurrence::ColorimetricTransformReleaseId::Iec61966Srgb8ToCie1931TwoDegreeXyzD65RelativeY1V1 => { + release_tag::IEC_SRGB8_TO_XYZ_D65_TRANSFORM_V1 + } + })?; + color.push_srgb8(signal.srgb8()) +} + +fn source_color(source: Source) -> Result { + let mut color = VertexColorV1::new(vertex_tag::SOURCE); + write_signal(&mut color, source.signal())?; + Ok(color) +} + +fn candidate_color(candidate: TargetCandidateV1) -> Result { + let mut color = VertexColorV1::new(vertex_tag::CANDIDATE); + write_signal(&mut color, candidate.signal())?; + Ok(color) +} + +fn opacity_color(input: OpacityInput) -> Result { + let admitted = crate::composition::AdmittedOpacityV1::new(input.value()) + .map_err(|_| ProgramCompileError::InternalInvariant)?; + let mut color = VertexColorV1::new(vertex_tag::OPACITY); + color.push_u64(admitted.bits())?; + Ok(color) +} + +fn write_context( + color: &mut VertexColorV1, + context: AppearanceContextId, +) -> Result<(), ProgramCompileError> { + color.push_u8(match context.schema_release() { + crate::lcs_occurrence::AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1 => { + release_tag::CIECAM16_VIEWING_INPUTS_V1 + } + })?; + let frame = context.frame(); + color.push_u8(match frame.observer() { + crate::lcs_occurrence::ObserverProfileId::Cie1931TwoDegreeV1 => { + release_tag::CIE1931_TWO_DEGREE_OBSERVER_V1 + } + })?; + color.push_u8(match frame.reference_white() { + crate::lcs_occurrence::ReferenceWhiteId::Iec61966D65ChromaticityV1 => { + release_tag::IEC61966_D65_REFERENCE_WHITE_V1 + } + })?; + color.push_u8(match frame.scale() { + crate::lcs_occurrence::TristimulusScale::RelativeY1 => release_tag::RELATIVE_Y1_SCALE_V1, + })?; + color.push_u8(match frame.release() { + crate::lcs_occurrence::ColorimetricFrameReleaseId::XyzV1 => release_tag::XYZ_FRAME_V1, + #[cfg(test)] + crate::lcs_occurrence::ColorimetricFrameReleaseId::MutationSentinelV1 => { + release_tag::MUTATION_SENTINEL_FRAME_V1 + } + })?; + color.push_u64(context.adapting_luminance_cd_m2().to_bits())?; + color.push_u64(context.background_luminance_ratio().to_bits())?; + color.push_u8(match context.surround_profile() { + crate::lcs_occurrence::SurroundProfileId::AverageV1 => release_tag::SURROUND_AVERAGE_V1, + crate::lcs_occurrence::SurroundProfileId::DimV1 => release_tag::SURROUND_DIM_V1, + crate::lcs_occurrence::SurroundProfileId::DarkV1 => release_tag::SURROUND_DARK_V1, + })?; + Ok(()) +} + +fn occurrence_color(occurrence: Occurrence) -> Result { + let mut color = VertexColorV1::new(vertex_tag::OCCURRENCE); + color.push_u8(match occurrence.composition() { + CompositionProfile::EncodedSrgb8SourceOverV1 => release_tag::ENCODED_SRGB8_SOURCE_OVER_V1, + })?; + write_context(&mut color, occurrence.context())?; + Ok(color) +} + +fn wcag_criterion_tag(criterion: Wcag22CriterionV1) -> u8 { + match criterion { + Wcag22CriterionV1::Sc143TextDefault => release_tag::WCAG22_SC_1_4_3_TEXT_DEFAULT, + Wcag22CriterionV1::Sc143TextLargeScale => release_tag::WCAG22_SC_1_4_3_TEXT_LARGE_SCALE, + Wcag22CriterionV1::Sc1411UiComponentOrState => { + release_tag::WCAG22_SC_1_4_11_UI_COMPONENT_OR_STATE + } + Wcag22CriterionV1::Sc1411GraphicalObject => release_tag::WCAG22_SC_1_4_11_GRAPHICAL_OBJECT, + } +} + +fn constraint_color( + mode_tag: u8, + content: ProgramConstraintContentV1, +) -> Result { + let mut color = VertexColorV1::new(mode_tag); + match content { + ProgramConstraintContentV1::ExactSrgb8 { + identity, + release, + capability, + expected, + } => { + color.push_u8(release_tag::EXACT_SRGB8_FAMILY_V1)?; + color.push_u8(match identity { + crate::constraints::ExactConstraintIdentityV1::FinalSrgb8IdentityV1 => { + release_tag::EXACT_SRGB8_IDENTITY_V1 + } + #[cfg(test)] + crate::constraints::ExactConstraintIdentityV1::MutationSentinelV1 => { + release_tag::EXACT_SRGB8_IDENTITY_MUTATION_SENTINEL_V1 + } + })?; + color.push_u8(match release { + crate::constraints::ExactIdentityReleaseV1::V1 => { + release_tag::EXACT_SRGB8_RELEASE_V1 + } + #[cfg(test)] + crate::constraints::ExactIdentityReleaseV1::MutationSentinelV1 => { + release_tag::EXACT_SRGB8_RELEASE_MUTATION_SENTINEL_V1 + } + })?; + color.push_u8(match capability { + crate::constraints::ExactIdentityCapabilityV1::FinalOccurrenceSrgb8IdentityV1 => { + release_tag::EXACT_SRGB8_CAPABILITY_V1 + } + #[cfg(test)] + crate::constraints::ExactIdentityCapabilityV1::MutationSentinelV1 => { + release_tag::EXACT_SRGB8_CAPABILITY_MUTATION_SENTINEL_V1 + } + })?; + color.push_srgb8(expected)?; + } + ProgramConstraintContentV1::Wcag22Srgb8 { + identity, + release, + capability, + criterion, + } => { + color.push_u8(release_tag::WCAG22_SRGB8_FAMILY_V1)?; + color.push_u8(match identity { + crate::constraints::Wcag22Srgb8EvaluatorIdentityV1 => { + release_tag::WCAG22_SRGB8_IDENTITY_V1 + } + })?; + color.push_u8(match release { + crate::wcag22::Wcag22ProfileIdV1::Wcag22Srgb8ContrastV1 => { + release_tag::WCAG22_SRGB8_PROFILE_V1 + } + })?; + color.push_u8(match capability { + crate::constraints::Wcag22Srgb8CapabilityV1 => { + release_tag::WCAG22_SRGB8_CAPABILITY_V1 + } + })?; + color.push_u8(wcag_criterion_tag(criterion))?; + } + #[cfg(test)] + ProgramConstraintContentV1::FinalRecheckMutantExactSrgb8 { expected } => { + for tag in [0xFE_u8, 1, 1, 1] { + color.push_u8(tag)?; + } + color.push_srgb8(expected)?; + } + } + Ok(color) +} + +fn build_graph( + program: &Program, +) -> Result +where + Evaluation: ProgramConstraintEvaluatorSetV1, + ProgramConstraintInvocationOf: Copy, +{ + let mut graph = GraphBuilderV1::new(program_root_color()?)?; + let mut sources = IdIndexV1::new(); + let mut targets = IdIndexV1::new(); + let mut candidates = IdIndexV1::new(); + let mut opacities = IdIndexV1::new(); + let mut paints = IdIndexV1::new(); + let mut ports = IdIndexV1::new(); + let mut surfaces = IdIndexV1::new(); + let mut occurrences = IdIndexV1::new(); + + for source in &program.sources { + sources.insert(source.id(), graph.add_member(source_color(*source)?)?)?; + } + for target in &program.targets { + let target_color = match target.domain() { + TargetDomainV1::Fixed => VertexColorV1::new(vertex_tag::TARGET_FIXED), + TargetDomainV1::Finite(_) => VertexColorV1::new(vertex_tag::TARGET_FINITE), + }; + let target_vertex = graph.add_member(target_color)?; + targets.insert(target.id(), target_vertex)?; + if let TargetDomainV1::Finite(domain) = target.domain() { + for candidate in domain { + let vertex = graph.add_member(candidate_color(*candidate)?)?; + candidates.insert((target.id(), candidate.id()), vertex)?; + } + } + } + for opacity in &program.opacities { + opacities.insert(opacity.id(), graph.add_member(opacity_color(*opacity)?)?)?; + } + for paint in &program.paints { + let (id, tag) = match paint { + Paint::Solid { id, .. } => (*id, vertex_tag::PAINT_SOLID), + Paint::Opacity { id, .. } => (*id, vertex_tag::PAINT_OPACITY), + }; + paints.insert(id, graph.add_member(VertexColorV1::new(tag))?)?; + } + + let group = graph.add_member(VertexColorV1::new(vertex_tag::OBSERVATION_GROUP))?; + for port in &program.observation_group.surface_input_ports { + ports.insert( + *port, + graph.add_member(VertexColorV1::new(vertex_tag::SURFACE_INPUT_PORT))?, + )?; + } + for surface in &program.surfaces { + let (id, tag) = match surface { + Surface::Input { id, .. } => (*id, vertex_tag::SURFACE_INPUT), + Surface::FromOccurrence { id, .. } => (*id, vertex_tag::SURFACE_FROM_OCCURRENCE), + }; + surfaces.insert(id, graph.add_member(VertexColorV1::new(tag))?)?; + } + for occurrence in &program.occurrences { + occurrences.insert( + occurrence.id(), + graph.add_member(occurrence_color(*occurrence)?)?, + )?; + } + + sources.finish()?; + targets.finish()?; + candidates.finish()?; + opacities.finish()?; + paints.finish()?; + ports.finish()?; + surfaces.finish()?; + occurrences.finish()?; + + for target in &program.targets { + let target_vertex = targets.get(target.id())?; + graph.add_edge( + target_vertex, + sources.get(target.source())?, + EdgeRoleV1::TargetSource, + )?; + if let TargetDomainV1::Finite(domain) = target.domain() { + for candidate in domain { + graph.add_edge( + target_vertex, + candidates.get((target.id(), candidate.id()))?, + EdgeRoleV1::TargetCandidate, + )?; + } + } + } + for paint in &program.paints { + match *paint { + Paint::Solid { id, target } => graph.add_edge( + paints.get(id)?, + targets.get(target)?, + EdgeRoleV1::SolidTarget, + )?, + Paint::Opacity { + id, + source, + opacity, + } => { + graph.add_edge( + paints.get(id)?, + paints.get(source)?, + EdgeRoleV1::OpacitySourcePaint, + )?; + graph.add_edge( + paints.get(id)?, + opacities.get(opacity)?, + EdgeRoleV1::OpacityInput, + )?; + } + } + } + for port in &program.observation_group.surface_input_ports { + graph.add_edge(group, ports.get(*port)?, EdgeRoleV1::ObservationGroupPort)?; + } + for surface in &program.surfaces { + match *surface { + Surface::Input { id, input } => graph.add_edge( + surfaces.get(id)?, + ports.get(input)?, + EdgeRoleV1::InputSurfacePort, + )?, + Surface::FromOccurrence { id, occurrence } => graph.add_edge( + surfaces.get(id)?, + occurrences.get(occurrence)?, + EdgeRoleV1::DerivedSurfaceOccurrence, + )?, + } + } + for occurrence in &program.occurrences { + graph.add_edge( + occurrences.get(occurrence.id())?, + paints.get(occurrence.subject())?, + EdgeRoleV1::OccurrenceSubjectPaint, + )?; + graph.add_edge( + occurrences.get(occurrence.id())?, + surfaces.get(occurrence.against())?, + EdgeRoleV1::OccurrenceBackdropSurface, + )?; + } + + for constraint in &program.constraints.hard { + let color = constraint_color( + vertex_tag::CONSTRAINT_HARD, + program.evaluator.constraint_content(constraint.invocation), + )?; + let vertex = graph.add_member(color)?; + graph.add_edge( + vertex, + occurrences.get(constraint.target)?, + EdgeRoleV1::ConstraintOccurrence, + )?; + } + for constraint in &program.constraints.report_only { + let color = constraint_color( + vertex_tag::CONSTRAINT_REPORT_ONLY, + program.evaluator.constraint_content(constraint.invocation), + )?; + let vertex = graph.add_member(color)?; + graph.add_edge( + vertex, + occurrences.get(constraint.target)?, + EdgeRoleV1::ConstraintOccurrence, + )?; + } + for output in &program.outputs { + let vertex = graph.add_member(VertexColorV1::new(vertex_tag::OUTPUT))?; + graph.add_edge(vertex, paints.get(output.paint())?, EdgeRoleV1::OutputPaint)?; + } + + if let Some(selection) = &program.joint_selection { + let selection_vertex = graph.add_member(VertexColorV1::new(vertex_tag::JOINT_SELECTION))?; + for (state_index, state) in selection.states().iter().enumerate() { + let state_index = + u64::try_from(state_index).map_err(|_| ProgramCompileError::ResourceExhausted)?; + let mut state_color = VertexColorV1::new(vertex_tag::JOINT_STATE); + state_color.push_u64(state_index)?; + let state_vertex = graph.add_member(state_color)?; + graph.add_edge(selection_vertex, state_vertex, EdgeRoleV1::SelectionState)?; + for choice in state.choices() { + let choice_vertex = + graph.add_member(VertexColorV1::new(vertex_tag::JOINT_CHOICE))?; + graph.add_edge(state_vertex, choice_vertex, EdgeRoleV1::StateChoice)?; + graph.add_edge( + choice_vertex, + targets.get(choice.target())?, + EdgeRoleV1::ChoiceTarget, + )?; + graph.add_edge( + choice_vertex, + candidates.get((choice.target(), choice.candidate()))?, + EdgeRoleV1::ChoiceCandidate, + )?; + } + } + } + + graph.finish() +} + +struct PartitionV1 { + cells: Vec>, +} + +impl PartitionV1 { + fn initial(graph: &CanonicalGraphV1) -> Result { + let mut order = Vec::new(); + order + .try_reserve_exact(graph.colors.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + order.extend(0..graph.colors.len()); + order.sort_unstable_by(|left, right| { + graph.colors[*left] + .cmp(&graph.colors[*right]) + .then_with(|| left.cmp(right)) + }); + + let mut cells = Vec::new(); + cells + .try_reserve_exact(graph.colors.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + let mut start = 0; + while start < order.len() { + let mut end = start + 1; + while end < order.len() && graph.colors[order[start]] == graph.colors[order[end]] { + end += 1; + } + cells.push(copy_vertices(&order[start..end])?); + start = end; + } + Ok(Self { cells }) + } + + fn is_discrete(&self) -> bool { + self.cells.iter().all(|cell| cell.len() == 1) + } + + fn first_non_singleton(&self) -> Option { + self.cells.iter().position(|cell| cell.len() > 1) + } +} + +fn copy_vertices(source: &[usize]) -> Result, ProgramCompileError> { + let mut copied = Vec::new(); + copied + .try_reserve_exact(source.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + copied.extend_from_slice(source); + Ok(copied) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +struct RefinementAtomV1 { + direction: u8, + role: EdgeRoleV1, + neighbour_cell: usize, +} + +struct RefinementRecordV1 { + vertex: usize, + signature: Vec, +} + +fn refine_partition( + graph: &CanonicalGraphV1, + mut partition: PartitionV1, +) -> Result { + loop { + let mut cell_of = Vec::new(); + cell_of + .try_reserve_exact(graph.colors.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + cell_of.resize(graph.colors.len(), usize::MAX); + for (cell_index, cell) in partition.cells.iter().enumerate() { + for &vertex in cell { + let slot = cell_of + .get_mut(vertex) + .ok_or(ProgramCompileError::InternalInvariant)?; + if *slot != usize::MAX { + return Err(ProgramCompileError::InternalInvariant); + } + *slot = cell_index; + } + } + if cell_of.contains(&usize::MAX) { + return Err(ProgramCompileError::InternalInvariant); + } + + let mut next_cells = Vec::new(); + next_cells + .try_reserve_exact(graph.colors.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + for cell in &partition.cells { + let mut records = Vec::new(); + records + .try_reserve_exact(cell.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + for &vertex in cell { + let arcs = graph + .adjacency + .get(vertex) + .ok_or(ProgramCompileError::InternalInvariant)?; + let mut signature = Vec::new(); + signature + .try_reserve_exact(arcs.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + for arc in arcs { + signature.push(RefinementAtomV1 { + direction: arc.direction, + role: arc.role, + neighbour_cell: cell_of[arc.neighbour], + }); + } + signature.sort_unstable(); + records.push(RefinementRecordV1 { vertex, signature }); + } + records.sort_unstable_by(|left, right| left.signature.cmp(&right.signature)); + + let mut start = 0; + while start < records.len() { + let mut end = start + 1; + while end < records.len() && records[start].signature == records[end].signature { + end += 1; + } + let mut split = Vec::new(); + split + .try_reserve_exact(end - start) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + split.extend(records[start..end].iter().map(|record| record.vertex)); + next_cells.push(split); + start = end; + } + } + + if next_cells.len() == partition.cells.len() { + partition.cells = next_cells; + return Ok(partition); + } + partition.cells = next_cells; + } +} + +fn individualize( + partition: &PartitionV1, + cell_index: usize, + vertex: usize, +) -> Result { + let selected = partition + .cells + .get(cell_index) + .ok_or(ProgramCompileError::InternalInvariant)?; + if selected.len() < 2 || !selected.contains(&vertex) { + return Err(ProgramCompileError::InternalInvariant); + } + + let mut cells = Vec::new(); + cells + .try_reserve_exact( + partition + .cells + .len() + .checked_add(1) + .ok_or(ProgramCompileError::ResourceExhausted)?, + ) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + for (index, cell) in partition.cells.iter().enumerate() { + if index != cell_index { + cells.push(copy_vertices(cell)?); + continue; + } + let mut singleton = Vec::new(); + singleton + .try_reserve_exact(1) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + singleton.push(vertex); + cells.push(singleton); + + let mut remainder = Vec::new(); + remainder + .try_reserve_exact(cell.len() - 1) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + remainder.extend( + cell.iter() + .copied() + .filter(|candidate| *candidate != vertex), + ); + cells.push(remainder); + } + Ok(PartitionV1 { cells }) +} + +struct SearchFrameV1 { + partition: PartitionV1, + branch_cell: Option, + candidates: Vec, + explored_candidates: Vec, + next_candidate: usize, + leaf_pending: bool, +} + +impl SearchFrameV1 { + fn new(partition: PartitionV1) -> Result { + let branch_cell = partition.first_non_singleton(); + let candidates = match branch_cell { + Some(index) => copy_vertices(&partition.cells[index])?, + None => Vec::new(), + }; + Ok(Self { + leaf_pending: branch_cell.is_none(), + partition, + branch_cell, + candidates, + explored_candidates: Vec::new(), + next_candidate: 0, + }) + } +} + +fn push_u64_bytes(output: &mut Vec, value: u64) { + output.extend_from_slice(&value.to_be_bytes()); +} + +fn usize_as_u64(value: usize) -> Result { + u64::try_from(value).map_err(|_| ProgramCompileError::ResourceExhausted) +} + +struct SerializedLeafV1 { + preimage: Vec, + order: Vec, +} + +fn serialize_leaf( + graph: &CanonicalGraphV1, + partition: &PartitionV1, +) -> Result { + if !partition.is_discrete() || partition.cells.len() != graph.colors.len() { + return Err(ProgramCompileError::InternalInvariant); + } + let mut order = Vec::new(); + order + .try_reserve_exact(graph.colors.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + let mut label_of = Vec::new(); + label_of + .try_reserve_exact(graph.colors.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + label_of.resize(graph.colors.len(), usize::MAX); + for (label, cell) in partition.cells.iter().enumerate() { + let [vertex] = cell.as_slice() else { + return Err(ProgramCompileError::InternalInvariant); + }; + label_of[*vertex] = label; + order.push(*vertex); + } + + let edge_bytes = graph + .edge_count + .checked_mul(9) + .ok_or(ProgramCompileError::ResourceExhausted)?; + let color_bytes = graph.colors.iter().try_fold(0_usize, |total, color| { + total + .checked_add(16) + .and_then(|value| value.checked_add(color.as_slice().len())) + .ok_or(ProgramCompileError::ResourceExhausted) + })?; + let capacity = DOMAIN_V1 + .len() + .checked_add(16) + .and_then(|value| value.checked_add(color_bytes)) + .and_then(|value| value.checked_add(edge_bytes)) + .ok_or(ProgramCompileError::ResourceExhausted)?; + let mut output = Vec::new(); + output + .try_reserve_exact(capacity) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + output.extend_from_slice(DOMAIN_V1); + push_u64_bytes(&mut output, usize_as_u64(graph.colors.len())?); + push_u64_bytes(&mut output, usize_as_u64(graph.edge_count)?); + + for cell in &partition.cells { + let vertex = cell[0]; + let color = graph.colors[vertex]; + push_u64_bytes(&mut output, u64::from(color.len)); + output.extend_from_slice(color.as_slice()); + + let outgoing_count = graph.adjacency[vertex] + .iter() + .filter(|arc| arc.direction == 0) + .count(); + push_u64_bytes(&mut output, usize_as_u64(outgoing_count)?); + let mut outgoing = Vec::new(); + outgoing + .try_reserve_exact(outgoing_count) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + outgoing.extend( + graph.adjacency[vertex] + .iter() + .filter(|arc| arc.direction == 0) + .map(|arc| (arc.role, label_of[arc.neighbour])), + ); + outgoing.sort_unstable(); + for (role, target) in outgoing { + output.push(role as u8); + push_u64_bytes(&mut output, usize_as_u64(target)?); + } + } + Ok(SerializedLeafV1 { + preimage: output, + order, + }) +} + +fn equal_leaf_automorphism( + canonical_order: &[usize], + equal_order: &[usize], +) -> Result, ProgramCompileError> { + if canonical_order.len() != equal_order.len() { + return Err(ProgramCompileError::InternalInvariant); + } + let mut permutation = Vec::new(); + permutation + .try_reserve_exact(canonical_order.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + permutation.resize(canonical_order.len(), usize::MAX); + for (&from, &to) in canonical_order.iter().zip(equal_order) { + let slot = permutation + .get_mut(from) + .ok_or(ProgramCompileError::InternalInvariant)?; + if *slot != usize::MAX || to >= canonical_order.len() { + return Err(ProgramCompileError::InternalInvariant); + } + *slot = to; + } + if permutation.contains(&usize::MAX) { + return Err(ProgramCompileError::InternalInvariant); + } + Ok(permutation) +} + +fn automorphism_preserves_partition( + permutation: &[usize], + cell_of: &[usize], +) -> Result { + if permutation.len() != cell_of.len() { + return Err(ProgramCompileError::InternalInvariant); + } + for (vertex, &image) in permutation.iter().enumerate() { + let image_cell = cell_of + .get(image) + .ok_or(ProgramCompileError::InternalInvariant)?; + if cell_of[vertex] != *image_cell { + return Ok(false); + } + } + Ok(true) +} + +/// Проверяет, лежит ли `candidate` в орбите уже исследованной ветви при +/// автоморфизмах, стабилизирующих текущее упорядоченное разбиение. Отсечение +/// точное: отображение сохраняется лишь после совпадения полных сериализаций +/// листьев, доказывающего автоморфизм графа. +fn candidate_is_in_explored_orbit( + partition: &PartitionV1, + explored: &[usize], + candidate: usize, + automorphisms: &[Vec], +) -> Result { + if explored.is_empty() || automorphisms.is_empty() { + return Ok(false); + } + let vertex_count = automorphisms[0].len(); + let mut cell_of = Vec::new(); + cell_of + .try_reserve_exact(vertex_count) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + cell_of.resize(vertex_count, usize::MAX); + for (cell_index, cell) in partition.cells.iter().enumerate() { + for &vertex in cell { + let slot = cell_of + .get_mut(vertex) + .ok_or(ProgramCompileError::InternalInvariant)?; + if *slot != usize::MAX { + return Err(ProgramCompileError::InternalInvariant); + } + *slot = cell_index; + } + } + if cell_of.contains(&usize::MAX) || candidate >= vertex_count { + return Err(ProgramCompileError::InternalInvariant); + } + + let mut stabilizers = Vec::new(); + stabilizers + .try_reserve_exact(automorphisms.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + for (index, permutation) in automorphisms.iter().enumerate() { + if automorphism_preserves_partition(permutation, &cell_of)? { + stabilizers.push(index); + } + } + if stabilizers.is_empty() { + return Ok(false); + } + + let mut seen = Vec::new(); + seen.try_reserve_exact(vertex_count) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + seen.resize(vertex_count, false); + let mut queue = Vec::new(); + queue + .try_reserve_exact(vertex_count) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + for &representative in explored { + let slot = seen + .get_mut(representative) + .ok_or(ProgramCompileError::InternalInvariant)?; + if !*slot { + *slot = true; + queue.push(representative); + } + } + let mut cursor = 0; + while cursor < queue.len() { + let vertex = queue[cursor]; + cursor += 1; + for &index in &stabilizers { + let image = automorphisms[index][vertex]; + let slot = seen + .get_mut(image) + .ok_or(ProgramCompileError::InternalInvariant)?; + if !*slot { + *slot = true; + queue.push(image); + } + } + } + Ok(seen[candidate]) +} + +/// Для одноцветных вершин с одинаковыми полными списками инцидентности +/// транспозиция сохраняет все типизированные рёбра. Это точное дешёвое +/// отсечение обрабатывает повторы до выделения общей перестановки. +fn candidate_is_exact_twin( + graph: &CanonicalGraphV1, + explored: &[usize], + candidate: usize, +) -> Result { + let candidate_color = graph + .colors + .get(candidate) + .ok_or(ProgramCompileError::InternalInvariant)?; + let candidate_arcs = graph + .adjacency + .get(candidate) + .ok_or(ProgramCompileError::InternalInvariant)?; + for &representative in explored { + if graph + .colors + .get(representative) + .ok_or(ProgramCompileError::InternalInvariant)? + == candidate_color + && graph + .adjacency + .get(representative) + .ok_or(ProgramCompileError::InternalInvariant)? + == candidate_arcs + { + return Ok(true); + } + } + Ok(false) +} + +fn canonical_search_impl( + graph: &CanonicalGraphV1, + mut remaining_branch_expansions: Option, +) -> Result<(Vec, usize), ProgramCompileError> { + let initial = refine_partition(graph, PartitionV1::initial(graph)?)?; + let mut stack = Vec::new(); + stack + .try_reserve_exact(graph.colors.len()) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + stack.push(SearchFrameV1::new(initial)?); + let mut best: Option = None; + let mut automorphisms: Vec> = Vec::new(); + let mut leaf_count = 0_usize; + + while !stack.is_empty() { + let leaf = stack + .last() + .map(|frame| frame.leaf_pending) + .ok_or(ProgramCompileError::InternalInvariant)?; + if leaf { + let candidate = { + let frame = stack + .last_mut() + .ok_or(ProgramCompileError::InternalInvariant)?; + frame.leaf_pending = false; + serialize_leaf(graph, &frame.partition)? + }; + stack.pop(); + // Диагностический счётчик не участвует в admission: насыщение не + // влияет на выбранный прообраз даже у недостижимо большого дерева. + leaf_count = leaf_count.saturating_add(1); + match &best { + None => best = Some(candidate), + Some(current) if candidate.preimage < current.preimage => best = Some(candidate), + Some(current) if candidate.preimage == current.preimage => { + let permutation = equal_leaf_automorphism(¤t.order, &candidate.order)?; + // Отсечение не требуется для корректности. Храним не более + // V доказанных автоморфизмов: при длине V это ограничивает + // память O(V²), а остальные ветви исследуются полностью. + if permutation.iter().enumerate().any(|(from, to)| from != *to) + && automorphisms.len() < graph.colors.len() + && !automorphisms.contains(&permutation) + { + automorphisms + .try_reserve(1) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + automorphisms.push(permutation); + } + } + Some(_) => {} + } + continue; + } + + let child = { + let frame = stack + .last_mut() + .ok_or(ProgramCompileError::InternalInvariant)?; + let mut selected = None; + while frame.next_candidate < frame.candidates.len() { + let candidate = frame.candidates[frame.next_candidate]; + frame.next_candidate += 1; + if candidate_is_exact_twin(graph, &frame.explored_candidates, candidate)? + || candidate_is_in_explored_orbit( + &frame.partition, + &frame.explored_candidates, + candidate, + &automorphisms, + )? + { + continue; + } + frame + .explored_candidates + .try_reserve(1) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + frame.explored_candidates.push(candidate); + selected = Some(candidate); + break; + } + match selected { + Some(candidate) => { + if let Some(remaining) = &mut remaining_branch_expansions { + *remaining = remaining + .checked_sub(1) + .ok_or(ProgramCompileError::ResourceExhausted)?; + } + let cell = frame + .branch_cell + .ok_or(ProgramCompileError::InternalInvariant)?; + Some(refine_partition( + graph, + individualize(&frame.partition, cell, candidate)?, + )?) + } + None => None, + } + }; + match child { + Some(partition) => { + stack + .try_reserve(1) + .map_err(|_| ProgramCompileError::ResourceExhausted)?; + stack.push(SearchFrameV1::new(partition)?); + } + None => { + stack.pop(); + } + } + } + let best = best.ok_or(ProgramCompileError::InternalInvariant)?; + Ok((best.preimage, leaf_count)) +} + +fn canonical_search(graph: &CanonicalGraphV1) -> Result<(Vec, usize), ProgramCompileError> { + // Число шагов поиска не инвариантно к изоморфизму: после opaque- + // переименования автоморфизм может обнаружиться другой ветвью. Поэтому + // динамический лимит сделал бы допуск зависимым от client-owned ID. Точный + // поиск возвращает только полный прообраз либо типизированный отказ. + canonical_search_impl(graph, None) +} + +#[cfg(test)] +fn canonical_search_with_test_fuel( + graph: &CanonicalGraphV1, + test_fuel: usize, +) -> Result<(Vec, usize), ProgramCompileError> { + // Только тестовая инъекция отказа для проверки атомарности. Это не политика + // допуска: история поиска не инвариантна к alpha-переименованию. + canonical_search_impl(graph, Some(test_fuel)) +} + +fn canonical_preimage(graph: &CanonicalGraphV1) -> Result, ProgramCompileError> { + canonical_search(graph).map(|(preimage, _)| preimage) +} + +pub(super) fn compile_program_content_identity_v1( + program: &Program, +) -> Result +where + Evaluation: ProgramConstraintEvaluatorSetV1, + ProgramConstraintInvocationOf: Copy, +{ + let graph = build_graph(program)?; + let preimage = canonical_preimage(&graph)?; + let digest = crate::sha256::digest(&preimage); + Ok(ProgramContentIdentityV1(*digest.as_bytes())) +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + + #[test] + fn evaluator_descriptor_and_certificate_metadata_have_one_source_of_truth() { + let evaluator = crate::constraints::ExactSrgb8IdentityV1; + let expected = Srgb8::new([0x12, 0x34, 0x56]); + let content = evaluator.program_constraint_content_v1(expected); + let ProgramConstraintContentV1::ExactSrgb8 { + identity, + release, + capability, + expected: described_expected, + } = content + else { + panic!("exact evaluator must describe its own exact invocation"); + }; + assert_eq!( + identity, + >::identity( + &evaluator + ) + ); + assert_eq!( + release, + >::release( + &evaluator + ) + ); + assert_eq!( + capability, + >::capability(&evaluator) + ); + assert_eq!(described_expected, expected); + + let baseline = constraint_color(vertex_tag::CONSTRAINT_HARD, content).unwrap(); + for mutant in [ + ProgramConstraintContentV1::ExactSrgb8 { + identity: crate::constraints::ExactConstraintIdentityV1::MutationSentinelV1, + release, + capability, + expected, + }, + ProgramConstraintContentV1::ExactSrgb8 { + identity, + release: crate::constraints::ExactIdentityReleaseV1::MutationSentinelV1, + capability, + expected, + }, + ProgramConstraintContentV1::ExactSrgb8 { + identity, + release, + capability: crate::constraints::ExactIdentityCapabilityV1::MutationSentinelV1, + expected, + }, + ] { + assert_ne!( + constraint_color(vertex_tag::CONSTRAINT_HARD, mutant).unwrap(), + baseline + ); + } + } + + fn context_color(context: AppearanceContextId) -> VertexColorV1 { + let mut color = VertexColorV1::new(vertex_tag::OCCURRENCE); + write_context(&mut color, context).unwrap(); + color + } + + #[test] + fn every_appearance_context_coordinate_and_frame_release_is_content_bound() { + use crate::lcs_occurrence::{ + AdaptingLuminanceCdM2, AppearanceContextSchemaReleaseId, BackgroundLuminanceRatio, + IEC_SRGB_D65_XYZ_FRAME_V1, MUTATION_SENTINEL_XYZ_FRAME_V1, SurroundProfileId, + }; + + let make = |frame, adapting_luminance, background_ratio, surround| { + AppearanceContextId::from_inputs( + AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1, + frame, + AdaptingLuminanceCdM2::try_new(adapting_luminance).unwrap(), + BackgroundLuminanceRatio::try_new(background_ratio).unwrap(), + surround, + ) + }; + let baseline = context_color(make( + IEC_SRGB_D65_XYZ_FRAME_V1, + 64.0, + 0.2, + SurroundProfileId::AverageV1, + )); + for mutant in [ + make( + IEC_SRGB_D65_XYZ_FRAME_V1, + 32.0, + 0.2, + SurroundProfileId::AverageV1, + ), + make( + IEC_SRGB_D65_XYZ_FRAME_V1, + 64.0, + 0.1, + SurroundProfileId::AverageV1, + ), + make( + IEC_SRGB_D65_XYZ_FRAME_V1, + 64.0, + 0.2, + SurroundProfileId::DimV1, + ), + make( + MUTATION_SENTINEL_XYZ_FRAME_V1, + 64.0, + 0.2, + SurroundProfileId::AverageV1, + ), + ] { + assert_ne!(context_color(mutant), baseline); + } + } + + #[test] + fn every_wcag_criterion_has_distinct_constraint_content() { + let evaluator = crate::constraints::Wcag22Srgb8V1; + let mut colors = Vec::new(); + for criterion in [ + Wcag22CriterionV1::Sc143TextDefault, + Wcag22CriterionV1::Sc143TextLargeScale, + Wcag22CriterionV1::Sc1411UiComponentOrState, + Wcag22CriterionV1::Sc1411GraphicalObject, + ] { + colors.push( + constraint_color( + vertex_tag::CONSTRAINT_HARD, + evaluator.program_constraint_content_v1(criterion), + ) + .unwrap(), + ); + } + colors.sort_unstable(); + colors.dedup(); + assert_eq!(colors.len(), 4); + } + + fn mapping_preserves_graph( + left: &CanonicalGraphV1, + right: &CanonicalGraphV1, + mapping: &[usize], + ) -> bool { + if left.colors.len() != right.colors.len() || left.edge_count != right.edge_count { + return false; + } + for (vertex, &image) in mapping.iter().enumerate() { + if left.colors[vertex] != right.colors[image] { + return false; + } + let mut left_arcs = left.adjacency[vertex] + .iter() + .map(|arc| (arc.direction, arc.role, mapping[arc.neighbour])) + .collect::>(); + let mut right_arcs = right.adjacency[image] + .iter() + .map(|arc| (arc.direction, arc.role, arc.neighbour)) + .collect::>(); + left_arcs.sort_unstable(); + right_arcs.sort_unstable(); + if left_arcs != right_arcs { + return false; + } + } + true + } + + fn visit_mappings( + left: &CanonicalGraphV1, + right: &CanonicalGraphV1, + mapping: &mut [usize], + cursor: usize, + ) -> bool { + if cursor == mapping.len() { + return mapping_preserves_graph(left, right, mapping); + } + for candidate in cursor..mapping.len() { + mapping.swap(cursor, candidate); + let color_matches = left.colors[cursor] == right.colors[mapping[cursor]]; + if color_matches && visit_mappings(left, right, mapping, cursor + 1) { + mapping.swap(cursor, candidate); + return true; + } + mapping.swap(cursor, candidate); + } + false + } + + fn brute_force_isomorphic(left: &CanonicalGraphV1, right: &CanonicalGraphV1) -> bool { + if left.colors.len() != right.colors.len() { + return false; + } + let mut mapping = (0..left.colors.len()).collect::>(); + visit_mappings(left, right, &mut mapping, 0) + } + + fn tiny_bipartite_graph(edge_mask: u8) -> CanonicalGraphV1 { + let mut graph = GraphBuilderV1::new(VertexColorV1::new(vertex_tag::PROGRAM)).unwrap(); + let sources = [(); 2].map(|()| { + graph + .add_member(VertexColorV1::new(vertex_tag::SOURCE)) + .unwrap() + }); + let targets = [(); 2].map(|()| { + graph + .add_member(VertexColorV1::new(vertex_tag::TARGET_FIXED)) + .unwrap() + }); + for (bit, (target, source)) in [ + (targets[0], sources[0]), + (targets[0], sources[1]), + (targets[1], sources[0]), + (targets[1], sources[1]), + ] + .into_iter() + .enumerate() + { + if edge_mask & (1 << bit) != 0 { + graph + .add_edge(target, source, EdgeRoleV1::TargetSource) + .unwrap(); + } + } + graph.finish().unwrap() + } + + #[test] + fn canonicalizer_matches_an_independent_tiny_isomorphism_oracle() { + let graphs = (0..16).map(tiny_bipartite_graph).collect::>(); + let preimages = graphs + .iter() + .map(|graph| canonical_preimage(graph).unwrap()) + .collect::>(); + + for left in 0..graphs.len() { + for right in 0..graphs.len() { + assert_eq!( + preimages[left] == preimages[right], + brute_force_isomorphic(&graphs[left], &graphs[right]), + "tiny bipartite masks {left:#06b} and {right:#06b}" + ); + } + } + } + + #[test] + fn exact_automorphism_pruning_prevents_factorial_symmetric_search() { + const SYMMETRIC_VERTICES: usize = 12; + let mut graph = GraphBuilderV1::new(VertexColorV1::new(vertex_tag::PROGRAM)).unwrap(); + for _ in 0..SYMMETRIC_VERTICES { + graph + .add_member(VertexColorV1::new(vertex_tag::SOURCE)) + .unwrap(); + } + let graph = graph.finish().unwrap(); + + let (_, leaves) = canonical_search(&graph).unwrap(); + + assert_eq!(leaves, 1, "exact twin pruning visited extra leaves"); + } + + #[test] + fn exhausted_fault_injection_fuel_never_returns_a_partial_preimage() { + let mut graph = GraphBuilderV1::new(VertexColorV1::new(vertex_tag::PROGRAM)).unwrap(); + graph + .add_member(VertexColorV1::new(vertex_tag::SOURCE)) + .unwrap(); + graph + .add_member(VertexColorV1::new(vertex_tag::SOURCE)) + .unwrap(); + let graph = graph.finish().unwrap(); + + assert_eq!( + canonical_search_with_test_fuel(&graph, 0).unwrap_err(), + ProgramCompileError::ResourceExhausted + ); + } + + fn relabelled_budget_graph(permutation: [usize; 9]) -> CanonicalGraphV1 { + const EDGES: [(usize, usize); 16] = [ + (1, 2), + (2, 1), + (3, 4), + (8, 1), + (4, 3), + (1, 8), + (6, 4), + (6, 7), + (7, 6), + (5, 6), + (5, 3), + (8, 2), + (7, 5), + (4, 7), + (3, 5), + (2, 8), + ]; + + let mut graph = GraphBuilderV1::new(VertexColorV1::new(vertex_tag::PROGRAM)).unwrap(); + for _ in 1..permutation.len() { + graph + .add_member(VertexColorV1::new(vertex_tag::SOURCE)) + .unwrap(); + } + for (from, to) in EDGES { + graph + .add_edge(permutation[from], permutation[to], EdgeRoleV1::TargetSource) + .unwrap(); + } + graph.finish().unwrap() + } + + fn permutation_from_keys(keys: [u64; N]) -> Vec { + let mut images = (0..N).collect::>(); + images.sort_unstable_by_key(|index| (keys[*index], *index)); + let mut permutation = vec![0]; + permutation.extend(images.into_iter().map(|index| index + 1)); + permutation + } + + fn small_directed_graph(edge_mask: u16, permutation: &[usize]) -> CanonicalGraphV1 { + let mut graph = GraphBuilderV1::new(VertexColorV1::new(vertex_tag::PROGRAM)).unwrap(); + for _ in 1..permutation.len() { + graph + .add_member(VertexColorV1::new(vertex_tag::SOURCE)) + .unwrap(); + } + let mut bit = 0; + for from in 1..permutation.len() { + for to in 1..permutation.len() { + if from != to && edge_mask & (1 << bit) != 0 { + graph + .add_edge(permutation[from], permutation[to], EdgeRoleV1::TargetSource) + .unwrap(); + } + if from != to { + bit += 1; + } + } + } + graph.finish().unwrap() + } + + #[test] + fn exact_preimage_is_invariant_under_opaque_relabelling() { + let canonical = relabelled_budget_graph([0, 1, 2, 3, 4, 5, 6, 7, 8]); + let renamed = relabelled_budget_graph([0, 1, 2, 6, 3, 5, 4, 7, 8]); + + let (canonical, _) = canonical_search(&canonical).unwrap(); + let (renamed, _) = canonical_search(&renamed).unwrap(); + + assert_eq!(canonical, renamed); + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(128))] + + #[test] + fn hostile_graph_preimage_is_invariant_for_generated_bijections( + keys in proptest::array::uniform8(any::()), + ) { + let permutation = permutation_from_keys(keys); + let permutation: [usize; 9] = permutation.try_into().unwrap(); + let baseline = relabelled_budget_graph([0, 1, 2, 3, 4, 5, 6, 7, 8]); + let renamed = relabelled_budget_graph(permutation); + + prop_assert_eq!( + canonical_search(&baseline).unwrap().0, + canonical_search(&renamed).unwrap().0, + ); + } + + #[test] + fn small_role_directed_graph_preimage_is_invariant_for_generated_bijections( + edge_mask in 0_u16..=0x0fff, + keys in proptest::array::uniform4(any::()), + ) { + let baseline = small_directed_graph(edge_mask, &[0, 1, 2, 3, 4]); + let renamed = small_directed_graph(edge_mask, &permutation_from_keys(keys)); + + prop_assert_eq!( + canonical_search(&baseline).unwrap().0, + canonical_search(&renamed).unwrap().0, + ); + } + } +} diff --git a/crates/labcolors-core/src/program_identity_tests.rs b/crates/labcolors-core/src/program_identity_tests.rs new file mode 100644 index 00000000..218c0b5e --- /dev/null +++ b/crates/labcolors-core/src/program_identity_tests.rs @@ -0,0 +1,1189 @@ +use crate::Srgb8; +use crate::appearance::{OccurrenceId, OpacityInputId, PaintId, SurfaceId, SurfaceInputPortId}; +use crate::lcs_occurrence::{ + AdaptingLuminanceCdM2, AppearanceContextId, AppearanceContextSchemaReleaseId, + BackgroundLuminanceRatio, ColorSignal, IEC_SRGB_D65_XYZ_FRAME_V1, SurroundProfileId, +}; +use crate::observation::ObservationGroupId; +use crate::program_session::{ + CompositionProfile, ConstraintId, ConstraintInvocation, ConstraintSet, + CoreProgramConstraintInvocationV1, CoreProgramEvaluatorsV1, CoreProgramV1, + DeclaredJointSelectionV1, JointCandidateStateV1, ObservationGroup, Occurrence, OpacityInput, + OutputBinding, OutputSlotId, Paint, Program, Source, SourceId, Surface, Target, + TargetCandidateChoiceV1, TargetCandidateId, TargetCandidateV1, TargetId, +}; +use crate::wcag22::Wcag22CriterionV1; + +fn signal(value: [u8; 3]) -> ColorSignal { + ColorSignal::from_srgb8(Srgb8::new(value)) +} + +fn context(surround: SurroundProfileId) -> AppearanceContextId { + AppearanceContextId::from_inputs( + AppearanceContextSchemaReleaseId::Ciecam16ViewingInputsV1, + IEC_SRGB_D65_XYZ_FRAME_V1, + AdaptingLuminanceCdM2::try_new(64.0).unwrap(), + BackgroundLuminanceRatio::try_new(0.2).unwrap(), + surround, + ) +} + +#[derive(Clone, Copy)] +struct FixedIds { + sources: [SourceId; 2], + targets: [TargetId; 2], + paints: [PaintId; 2], + ports: [SurfaceInputPortId; 2], + surfaces: [SurfaceId; 2], + occurrences: [OccurrenceId; 2], + constraints: [ConstraintId; 2], + outputs: [OutputSlotId; 2], + group: ObservationGroupId, +} + +#[derive(Clone, Copy)] +enum FixedMutation { + None, + SourceSignal, + TargetSource, +} + +fn fixed_program( + ids: FixedIds, + reverse_declarations: bool, + second_signal: Srgb8, + mutation: FixedMutation, +) -> CoreProgramV1 { + let mut sources = vec![ + Source::new(ids.sources[0], signal([0x10, 0x20, 0x30])), + Source::new( + ids.sources[1], + ColorSignal::from_srgb8(if matches!(mutation, FixedMutation::SourceSignal) { + Srgb8::new([0x41, 0x50, 0x60]) + } else { + second_signal + }), + ), + ]; + let mut targets = vec![ + Target::fixed(ids.targets[0], ids.sources[0]), + Target::fixed( + ids.targets[1], + if matches!(mutation, FixedMutation::TargetSource) { + ids.sources[0] + } else { + ids.sources[1] + }, + ), + ]; + let mut paints = vec![ + Paint::Solid { + id: ids.paints[0], + target: ids.targets[0], + }, + Paint::Solid { + id: ids.paints[1], + target: ids.targets[1], + }, + ]; + let mut surfaces = vec![ + Surface::Input { + id: ids.surfaces[0], + input: ids.ports[0], + }, + Surface::Input { + id: ids.surfaces[1], + input: ids.ports[1], + }, + ]; + let mut occurrences = vec![ + Occurrence::new( + ids.occurrences[0], + ids.paints[0], + ids.surfaces[0], + CompositionProfile::EncodedSrgb8SourceOverV1, + context(SurroundProfileId::AverageV1), + ), + Occurrence::new( + ids.occurrences[1], + ids.paints[1], + ids.surfaces[1], + CompositionProfile::EncodedSrgb8SourceOverV1, + context(SurroundProfileId::DimV1), + ), + ]; + let mut hard = vec![ + ConstraintInvocation::hard( + ids.constraints[0], + ids.occurrences[0], + CoreProgramConstraintInvocationV1::ExactSrgb8(Srgb8::new([0x10, 0x20, 0x30])), + ), + ConstraintInvocation::hard( + ids.constraints[1], + ids.occurrences[1], + CoreProgramConstraintInvocationV1::ExactSrgb8(second_signal), + ), + ]; + let mut outputs = vec![ + OutputBinding::new(ids.outputs[0], ids.paints[0]), + OutputBinding::new(ids.outputs[1], ids.paints[1]), + ]; + let mut ports = ids.ports.to_vec(); + + if reverse_declarations { + sources.reverse(); + targets.reverse(); + paints.reverse(); + surfaces.reverse(); + occurrences.reverse(); + hard.reverse(); + outputs.reverse(); + ports.reverse(); + } + + Program::new( + sources, + targets, + ObservationGroup::new(ids.group, ports), + vec![], + paints, + surfaces, + occurrences, + ConstraintSet::new(hard, vec![]), + outputs, + CoreProgramEvaluatorsV1, + ) +} + +#[test] +fn fixed_graph_identity_ignores_opaque_names_and_unordered_declaration_order() { + let canonical = FixedIds { + sources: [SourceId::new(10), SourceId::new(20)], + targets: [TargetId::new(30), TargetId::new(40)], + paints: [PaintId::new(50), PaintId::new(60)], + ports: [SurfaceInputPortId::new(70), SurfaceInputPortId::new(80)], + surfaces: [SurfaceId::new(90), SurfaceId::new(100)], + occurrences: [OccurrenceId::new(110), OccurrenceId::new(120)], + constraints: [ConstraintId::new(130), ConstraintId::new(140)], + outputs: [OutputSlotId::new(150), OutputSlotId::new(160)], + group: ObservationGroupId::new(170), + }; + let renamed = FixedIds { + sources: [SourceId::new(902), SourceId::new(101)], + targets: [TargetId::new(804), TargetId::new(203)], + paints: [PaintId::new(706), PaintId::new(305)], + ports: [SurfaceInputPortId::new(608), SurfaceInputPortId::new(407)], + surfaces: [SurfaceId::new(510), SurfaceId::new(409)], + occurrences: [OccurrenceId::new(312), OccurrenceId::new(211)], + constraints: [ConstraintId::new(114), ConstraintId::new(913)], + outputs: [OutputSlotId::new(816), OutputSlotId::new(715)], + group: ObservationGroupId::new(617), + }; + + let canonical = fixed_program( + canonical, + false, + Srgb8::new([0x40, 0x50, 0x60]), + FixedMutation::None, + ) + .compile() + .unwrap(); + let renamed = fixed_program( + renamed, + true, + Srgb8::new([0x40, 0x50, 0x60]), + FixedMutation::None, + ) + .compile() + .unwrap(); + + assert_eq!(canonical.content_identity(), renamed.content_identity()); +} + +#[test] +fn canonical_v1_digest_is_cross_platform_golden() { + let ids = FixedIds { + sources: [SourceId::new(10), SourceId::new(20)], + targets: [TargetId::new(30), TargetId::new(40)], + paints: [PaintId::new(50), PaintId::new(60)], + ports: [SurfaceInputPortId::new(70), SurfaceInputPortId::new(80)], + surfaces: [SurfaceId::new(90), SurfaceId::new(100)], + occurrences: [OccurrenceId::new(110), OccurrenceId::new(120)], + constraints: [ConstraintId::new(130), ConstraintId::new(140)], + outputs: [OutputSlotId::new(150), OutputSlotId::new(160)], + group: ObservationGroupId::new(170), + }; + let compiled = fixed_program( + ids, + false, + Srgb8::new([0x40, 0x50, 0x60]), + FixedMutation::None, + ) + .compile() + .unwrap(); + + assert_eq!( + compiled.content_identity().as_bytes(), + &[ + 168, 248, 71, 93, 18, 147, 245, 229, 235, 83, 237, 210, 202, 114, 6, 19, 16, 224, 85, + 63, 190, 86, 219, 66, 99, 226, 22, 200, 208, 224, 149, 196, + ] + ); +} + +#[test] +fn source_signal_and_target_source_edge_are_independently_content_bound() { + let ids = FixedIds { + sources: [SourceId::new(10), SourceId::new(20)], + targets: [TargetId::new(30), TargetId::new(40)], + paints: [PaintId::new(50), PaintId::new(60)], + ports: [SurfaceInputPortId::new(70), SurfaceInputPortId::new(80)], + surfaces: [SurfaceId::new(90), SurfaceId::new(100)], + occurrences: [OccurrenceId::new(110), OccurrenceId::new(120)], + constraints: [ConstraintId::new(130), ConstraintId::new(140)], + outputs: [OutputSlotId::new(150), OutputSlotId::new(160)], + group: ObservationGroupId::new(170), + }; + let baseline = fixed_program( + ids, + false, + Srgb8::new([0x40, 0x50, 0x60]), + FixedMutation::None, + ) + .compile() + .unwrap() + .content_identity(); + let changed_signal = fixed_program( + ids, + false, + Srgb8::new([0x40, 0x50, 0x60]), + FixedMutation::SourceSignal, + ) + .compile() + .unwrap() + .content_identity(); + let changed_target_source = fixed_program( + ids, + false, + Srgb8::new([0x40, 0x50, 0x60]), + FixedMutation::TargetSource, + ) + .compile() + .unwrap() + .content_identity(); + + assert_ne!(changed_signal, baseline); + assert_ne!(changed_target_source, baseline); +} + +#[derive(Clone, Copy)] +struct FullIds { + sources: [SourceId; 2], + targets: [TargetId; 2], + candidates: [[TargetCandidateId; 2]; 2], + opacities: [OpacityInputId; 2], + paints: [PaintId; 4], + port: SurfaceInputPortId, + surfaces: [SurfaceId; 2], + occurrences: [OccurrenceId; 3], + constraints: [ConstraintId; 3], + outputs: [OutputSlotId; 2], + group: ObservationGroupId, +} + +#[derive(Debug, Clone, Copy)] +enum FullMutation { + None, + CompleteSchemaGolden, + CandidateSignal, + OpacityValue, + OpacityPositiveZero, + OpacityNegativeZero, + PaintTarget, + OpacitySource, + OpacityInput, + OccurrenceSubject, + Context, + ConstraintTarget, + ConstraintMode, + ConstraintFamily, + ConstraintInvocation, + ConstraintMultiplicity, + OutputBinding, + OutputMultiplicity, +} + +fn full_program(ids: FullIds, reverse_unordered: bool, mutation: FullMutation) -> CoreProgramV1 { + let mut candidate_signals = [ + [signal([0x10, 0x20, 0x30]), signal([0x30, 0x20, 0x10])], + [signal([0x20, 0x60, 0x40]), signal([0x60, 0x40, 0x20])], + ]; + if matches!(mutation, FullMutation::CandidateSignal) { + candidate_signals[1][1] = signal([0x61, 0x40, 0x20]); + } + let mut sources = vec![ + Source::new(ids.sources[0], signal([0x08, 0x10, 0x18])), + Source::new(ids.sources[1], signal([0x18, 0x10, 0x08])), + ]; + let mut targets = (0..2) + .map(|target| { + let mut candidates = (0..2) + .map(|candidate| { + TargetCandidateV1::new( + ids.candidates[target][candidate], + candidate_signals[target][candidate], + ) + }) + .collect::>(); + if reverse_unordered { + candidates.reverse(); + } + Target::finite(ids.targets[target], ids.sources[target], candidates) + }) + .collect::>(); + let mut paints = vec![ + Paint::Solid { + id: ids.paints[0], + target: if matches!(mutation, FullMutation::PaintTarget) { + ids.targets[1] + } else { + ids.targets[0] + }, + }, + Paint::Opacity { + id: ids.paints[1], + source: if matches!(mutation, FullMutation::OpacitySource) { + ids.paints[2] + } else { + ids.paints[0] + }, + opacity: if matches!(mutation, FullMutation::OpacityInput) { + ids.opacities[1] + } else { + ids.opacities[0] + }, + }, + Paint::Solid { + id: ids.paints[2], + target: ids.targets[1], + }, + Paint::Solid { + id: ids.paints[3], + target: ids.targets[0], + }, + ]; + let mut surfaces = vec![ + Surface::Input { + id: ids.surfaces[0], + input: ids.port, + }, + Surface::FromOccurrence { + id: ids.surfaces[1], + occurrence: ids.occurrences[0], + }, + ]; + let mut occurrences = vec![ + Occurrence::new( + ids.occurrences[0], + if matches!(mutation, FullMutation::OccurrenceSubject) { + ids.paints[2] + } else { + ids.paints[1] + }, + ids.surfaces[0], + CompositionProfile::EncodedSrgb8SourceOverV1, + context(SurroundProfileId::AverageV1), + ), + Occurrence::new( + ids.occurrences[1], + ids.paints[2], + ids.surfaces[1], + CompositionProfile::EncodedSrgb8SourceOverV1, + context(if matches!(mutation, FullMutation::Context) { + SurroundProfileId::DarkV1 + } else { + SurroundProfileId::DimV1 + }), + ), + Occurrence::new( + ids.occurrences[2], + ids.paints[3], + ids.surfaces[1], + CompositionProfile::EncodedSrgb8SourceOverV1, + context(SurroundProfileId::DarkV1), + ), + ]; + let mut hard = vec![ConstraintInvocation::hard( + ids.constraints[0], + if matches!(mutation, FullMutation::ConstraintTarget) { + ids.occurrences[1] + } else { + ids.occurrences[0] + }, + CoreProgramConstraintInvocationV1::ExactSrgb8(Srgb8::new([0x11, 0x22, 0x33])), + )]; + let second_invocation = if matches!(mutation, FullMutation::ConstraintFamily) { + CoreProgramConstraintInvocationV1::Wcag22Srgb8(Wcag22CriterionV1::Sc143TextDefault) + } else { + CoreProgramConstraintInvocationV1::ExactSrgb8(Srgb8::new( + if matches!(mutation, FullMutation::ConstraintInvocation) { + [0x44, 0x55, 0x67] + } else { + [0x44, 0x55, 0x66] + }, + )) + }; + let mut report_only = Vec::new(); + if matches!(mutation, FullMutation::ConstraintMode) { + report_only.push(ConstraintInvocation::report_only( + ids.constraints[1], + ids.occurrences[1], + second_invocation, + )); + } else { + hard.push(ConstraintInvocation::hard( + ids.constraints[1], + ids.occurrences[1], + second_invocation, + )); + } + if matches!(mutation, FullMutation::CompleteSchemaGolden) { + report_only.push(ConstraintInvocation::report_only( + ConstraintId::new(1_001), + ids.occurrences[2], + CoreProgramConstraintInvocationV1::Wcag22Srgb8( + Wcag22CriterionV1::Sc1411GraphicalObject, + ), + )); + } + hard.push(ConstraintInvocation::hard( + ids.constraints[2], + ids.occurrences[2], + CoreProgramConstraintInvocationV1::ExactSrgb8(Srgb8::new([0x21, 0x32, 0x43])), + )); + if matches!(mutation, FullMutation::ConstraintMultiplicity) { + hard.push(ConstraintInvocation::hard( + ConstraintId::new(1_000), + ids.occurrences[1], + CoreProgramConstraintInvocationV1::ExactSrgb8(Srgb8::new([0x44, 0x55, 0x66])), + )); + } + let mut outputs = vec![ + OutputBinding::new( + ids.outputs[0], + if matches!(mutation, FullMutation::OutputBinding) { + ids.paints[1] + } else { + ids.paints[2] + }, + ), + OutputBinding::new(ids.outputs[1], ids.paints[3]), + ]; + if matches!(mutation, FullMutation::OutputMultiplicity) { + outputs.push(OutputBinding::new(OutputSlotId::new(1_000), ids.paints[0])); + } + let mut opacities = vec![ + OpacityInput::new( + ids.opacities[0], + match mutation { + FullMutation::OpacityValue => 0.5, + FullMutation::OpacityPositiveZero => 0.0, + FullMutation::OpacityNegativeZero => -0.0, + _ => 0.625, + }, + ), + OpacityInput::new(ids.opacities[1], 0.25), + ]; + if reverse_unordered { + sources.reverse(); + targets.reverse(); + opacities.reverse(); + paints.reverse(); + surfaces.reverse(); + occurrences.reverse(); + hard.reverse(); + report_only.reverse(); + outputs.reverse(); + } + + let mut states = Vec::new(); + for first in 0..2 { + for second in 0..2 { + let mut choices = vec![ + TargetCandidateChoiceV1::new(ids.targets[0], ids.candidates[0][first]), + TargetCandidateChoiceV1::new(ids.targets[1], ids.candidates[1][second]), + ]; + if reverse_unordered { + choices.reverse(); + } + states.push(JointCandidateStateV1::new(choices)); + } + } + + Program::new( + sources, + targets, + ObservationGroup::new(ids.group, vec![ids.port]), + opacities, + paints, + surfaces, + occurrences, + ConstraintSet::new(hard, report_only), + outputs, + CoreProgramEvaluatorsV1, + ) + .with_joint_selection(DeclaredJointSelectionV1::new(states)) +} + +fn canonical_full_ids() -> FullIds { + FullIds { + sources: [SourceId::new(1), SourceId::new(2)], + targets: [TargetId::new(3), TargetId::new(4)], + candidates: [ + [TargetCandidateId::new(5), TargetCandidateId::new(6)], + [TargetCandidateId::new(7), TargetCandidateId::new(8)], + ], + opacities: [OpacityInputId::new(9), OpacityInputId::new(10)], + paints: [ + PaintId::new(11), + PaintId::new(12), + PaintId::new(13), + PaintId::new(14), + ], + port: SurfaceInputPortId::new(15), + surfaces: [SurfaceId::new(16), SurfaceId::new(17)], + occurrences: [ + OccurrenceId::new(18), + OccurrenceId::new(19), + OccurrenceId::new(20), + ], + constraints: [ + ConstraintId::new(21), + ConstraintId::new(22), + ConstraintId::new(23), + ], + outputs: [OutputSlotId::new(24), OutputSlotId::new(25)], + group: ObservationGroupId::new(26), + } +} + +#[test] +fn complete_program_schema_v1_digest_is_cross_platform_golden() { + // Вместе с fixed golden этот Program содержит каждый V1 vertex/edge tag, + // обе constraint families и оба режима. Случайная смена кодировки требует + // явной смены версии, а не тихого перевыпуска прежнего content address. + let compiled = full_program( + canonical_full_ids(), + false, + FullMutation::CompleteSchemaGolden, + ) + .compile() + .unwrap(); + + assert_eq!( + compiled.content_identity().as_bytes(), + &[ + 31, 240, 88, 38, 57, 68, 24, 218, 176, 123, 232, 154, 83, 136, 136, 238, 75, 62, 8, + 163, 188, 120, 229, 152, 163, 217, 101, 60, 245, 191, 167, 219, + ] + ); +} + +#[test] +fn every_typed_opaque_namespace_and_unordered_list_is_alpha_invariant() { + let canonical = canonical_full_ids(); + let renamed = FullIds { + sources: [SourceId::new(2), SourceId::new(1)], + targets: [TargetId::new(2), TargetId::new(1)], + candidates: [ + [TargetCandidateId::new(2), TargetCandidateId::new(1)], + [TargetCandidateId::new(2), TargetCandidateId::new(1)], + ], + opacities: [OpacityInputId::new(2), OpacityInputId::new(1)], + paints: [ + PaintId::new(4), + PaintId::new(3), + PaintId::new(2), + PaintId::new(1), + ], + port: SurfaceInputPortId::new(1), + surfaces: [SurfaceId::new(2), SurfaceId::new(1)], + occurrences: [ + OccurrenceId::new(3), + OccurrenceId::new(2), + OccurrenceId::new(1), + ], + constraints: [ + ConstraintId::new(3), + ConstraintId::new(2), + ConstraintId::new(1), + ], + outputs: [OutputSlotId::new(2), OutputSlotId::new(1)], + group: ObservationGroupId::new(1), + }; + + let canonical = full_program(canonical, false, FullMutation::None) + .compile() + .unwrap(); + let renamed = full_program(renamed, true, FullMutation::None) + .compile() + .unwrap(); + + assert_eq!(canonical.content_identity(), renamed.content_identity()); +} + +#[test] +fn independent_program_content_mutations_change_identity() { + let ids = canonical_full_ids(); + let baseline = full_program(ids, false, FullMutation::None) + .compile() + .unwrap() + .content_identity(); + + for mutation in [ + FullMutation::CandidateSignal, + FullMutation::OpacityValue, + FullMutation::PaintTarget, + FullMutation::OpacitySource, + FullMutation::OpacityInput, + FullMutation::OccurrenceSubject, + FullMutation::Context, + FullMutation::ConstraintTarget, + FullMutation::ConstraintMode, + FullMutation::ConstraintFamily, + FullMutation::ConstraintInvocation, + FullMutation::ConstraintMultiplicity, + FullMutation::OutputBinding, + FullMutation::OutputMultiplicity, + ] { + let compiled = full_program(ids, false, mutation) + .compile() + .unwrap_or_else(|error| panic!("{mutation:?} must remain valid: {error:?}")); + assert_ne!(compiled.content_identity(), baseline, "{mutation:?}"); + } +} + +#[test] +fn signed_zero_opacity_has_one_physical_content_identity() { + let ids = canonical_full_ids(); + + let positive = full_program(ids, false, FullMutation::OpacityPositiveZero) + .compile() + .unwrap(); + let negative = full_program(ids, false, FullMutation::OpacityNegativeZero) + .compile() + .unwrap(); + + assert_eq!(positive.content_identity(), negative.content_identity()); +} + +fn nested_surface_program( + surface_from_second_occurrence: bool, + third_uses_nested_surface: bool, +) -> CoreProgramV1 { + let sources = [SourceId::new(1), SourceId::new(2)]; + let targets = [TargetId::new(3), TargetId::new(4)]; + let paints = [PaintId::new(5), PaintId::new(6)]; + let port = SurfaceInputPortId::new(7); + let surfaces = [SurfaceId::new(8), SurfaceId::new(9)]; + let occurrences = [ + OccurrenceId::new(10), + OccurrenceId::new(11), + OccurrenceId::new(12), + ]; + + Program::new( + vec![ + Source::new(sources[0], signal([0x20, 0x30, 0x40])), + Source::new(sources[1], signal([0x70, 0x60, 0x50])), + ], + vec![ + Target::fixed(targets[0], sources[0]), + Target::fixed(targets[1], sources[1]), + ], + ObservationGroup::new(ObservationGroupId::new(13), vec![port]), + vec![], + vec![ + Paint::Solid { + id: paints[0], + target: targets[0], + }, + Paint::Solid { + id: paints[1], + target: targets[1], + }, + ], + vec![ + Surface::Input { + id: surfaces[0], + input: port, + }, + Surface::FromOccurrence { + id: surfaces[1], + occurrence: occurrences[usize::from(surface_from_second_occurrence)], + }, + ], + vec![ + Occurrence::new( + occurrences[0], + paints[0], + surfaces[0], + CompositionProfile::EncodedSrgb8SourceOverV1, + context(SurroundProfileId::AverageV1), + ), + Occurrence::new( + occurrences[1], + paints[1], + surfaces[0], + CompositionProfile::EncodedSrgb8SourceOverV1, + context(SurroundProfileId::DimV1), + ), + Occurrence::new( + occurrences[2], + paints[0], + surfaces[usize::from(third_uses_nested_surface)], + CompositionProfile::EncodedSrgb8SourceOverV1, + context(SurroundProfileId::DarkV1), + ), + ], + ConstraintSet::new( + occurrences + .iter() + .copied() + .enumerate() + .map(|(index, occurrence)| { + ConstraintInvocation::hard( + ConstraintId::new(14 + index as u32), + occurrence, + CoreProgramConstraintInvocationV1::ExactSrgb8(Srgb8::new([ + 0x20 + index as u8, + 0x30, + 0x40, + ])), + ) + }) + .collect(), + vec![], + ), + vec![ + OutputBinding::new(OutputSlotId::new(17), paints[0]), + OutputBinding::new(OutputSlotId::new(18), paints[1]), + ], + CoreProgramEvaluatorsV1, + ) +} + +#[test] +fn surface_and_occurrence_relations_are_content_bound() { + let baseline = nested_surface_program(false, true) + .compile() + .unwrap() + .content_identity(); + let changed_surface_source = nested_surface_program(true, true) + .compile() + .unwrap() + .content_identity(); + let changed_occurrence_backdrop = nested_surface_program(false, false) + .compile() + .unwrap() + .content_identity(); + + assert_ne!(changed_surface_source, baseline); + assert_ne!(changed_occurrence_backdrop, baseline); +} + +#[derive(Clone, Copy)] +enum SubjectPaintShape { + OpacityFromFirst, + OpacityFromSecond, + Solid, +} + +fn paint_shape_program(shape: SubjectPaintShape) -> CoreProgramV1 { + let sources = [SourceId::new(1), SourceId::new(2)]; + let targets = [TargetId::new(3), TargetId::new(4)]; + let paints = [PaintId::new(5), PaintId::new(6), PaintId::new(7)]; + let opacity = OpacityInputId::new(8); + let port = SurfaceInputPortId::new(9); + let surface = SurfaceId::new(10); + let occurrence = OccurrenceId::new(11); + let subject = match shape { + SubjectPaintShape::OpacityFromFirst => Paint::Opacity { + id: paints[1], + source: paints[0], + opacity, + }, + SubjectPaintShape::OpacityFromSecond => Paint::Opacity { + id: paints[1], + source: paints[2], + opacity, + }, + SubjectPaintShape::Solid => Paint::Solid { + id: paints[1], + target: targets[0], + }, + }; + + Program::new( + vec![ + Source::new(sources[0], signal([0x20, 0x30, 0x40])), + Source::new(sources[1], signal([0x70, 0x60, 0x50])), + ], + vec![ + Target::fixed(targets[0], sources[0]), + Target::fixed(targets[1], sources[1]), + ], + ObservationGroup::new(ObservationGroupId::new(12), vec![port]), + vec![OpacityInput::new(opacity, 0.5)], + vec![ + Paint::Solid { + id: paints[0], + target: targets[0], + }, + subject, + Paint::Solid { + id: paints[2], + target: targets[1], + }, + ], + vec![Surface::Input { + id: surface, + input: port, + }], + vec![Occurrence::new( + occurrence, + paints[1], + surface, + CompositionProfile::EncodedSrgb8SourceOverV1, + context(SurroundProfileId::AverageV1), + )], + ConstraintSet::new( + vec![ConstraintInvocation::hard( + ConstraintId::new(13), + occurrence, + CoreProgramConstraintInvocationV1::ExactSrgb8(Srgb8::new([0x20, 0x30, 0x40])), + )], + vec![], + ), + vec![OutputBinding::new(OutputSlotId::new(14), paints[1])], + CoreProgramEvaluatorsV1, + ) +} + +#[test] +fn paint_variant_and_dependency_edges_are_content_bound() { + let baseline = paint_shape_program(SubjectPaintShape::OpacityFromFirst) + .compile() + .unwrap() + .content_identity(); + let changed_source = paint_shape_program(SubjectPaintShape::OpacityFromSecond) + .compile() + .unwrap() + .content_identity(); + let changed_variant = paint_shape_program(SubjectPaintShape::Solid) + .compile() + .unwrap() + .content_identity(); + + assert_ne!(changed_source, baseline); + assert_ne!(changed_variant, baseline); +} + +fn source_alias_program(shared: bool) -> CoreProgramV1 { + let sources = if shared { + vec![Source::new(SourceId::new(1), signal([0x30, 0x40, 0x50]))] + } else { + vec![ + Source::new(SourceId::new(1), signal([0x30, 0x40, 0x50])), + Source::new(SourceId::new(2), signal([0x30, 0x40, 0x50])), + ] + }; + let targets = [TargetId::new(3), TargetId::new(4)]; + let paints = [PaintId::new(5), PaintId::new(6)]; + let port = SurfaceInputPortId::new(7); + let surface = SurfaceId::new(8); + let occurrences = [OccurrenceId::new(9), OccurrenceId::new(10)]; + Program::new( + sources, + vec![ + Target::fixed(targets[0], SourceId::new(1)), + Target::fixed( + targets[1], + if shared { + SourceId::new(1) + } else { + SourceId::new(2) + }, + ), + ], + ObservationGroup::new(ObservationGroupId::new(11), vec![port]), + vec![], + vec![ + Paint::Solid { + id: paints[0], + target: targets[0], + }, + Paint::Solid { + id: paints[1], + target: targets[1], + }, + ], + vec![Surface::Input { + id: surface, + input: port, + }], + vec![ + Occurrence::new( + occurrences[0], + paints[0], + surface, + CompositionProfile::EncodedSrgb8SourceOverV1, + context(SurroundProfileId::AverageV1), + ), + Occurrence::new( + occurrences[1], + paints[1], + surface, + CompositionProfile::EncodedSrgb8SourceOverV1, + context(SurroundProfileId::AverageV1), + ), + ], + ConstraintSet::new( + vec![ + ConstraintInvocation::hard( + ConstraintId::new(12), + occurrences[0], + CoreProgramConstraintInvocationV1::ExactSrgb8(Srgb8::new([0x30, 0x40, 0x50])), + ), + ConstraintInvocation::hard( + ConstraintId::new(13), + occurrences[1], + CoreProgramConstraintInvocationV1::ExactSrgb8(Srgb8::new([0x30, 0x40, 0x50])), + ), + ], + vec![], + ), + vec![ + OutputBinding::new(OutputSlotId::new(14), paints[0]), + OutputBinding::new(OutputSlotId::new(15), paints[1]), + ], + CoreProgramEvaluatorsV1, + ) +} + +#[test] +fn shared_content_and_equal_duplicated_content_have_distinct_identity() { + let shared = source_alias_program(true).compile().unwrap(); + let duplicated = source_alias_program(false).compile().unwrap(); + + assert_ne!(shared.content_identity(), duplicated.content_identity()); +} + +fn finite_program(reverse_order: bool) -> CoreProgramV1 { + let source = SourceId::new(1); + let target = TargetId::new(2); + let first = TargetCandidateId::new(3); + let second = TargetCandidateId::new(4); + let paint = PaintId::new(5); + let port = SurfaceInputPortId::new(6); + let surface = SurfaceId::new(7); + let occurrence = OccurrenceId::new(8); + let states = [first, second].map(|candidate| { + JointCandidateStateV1::new(vec![TargetCandidateChoiceV1::new(target, candidate)]) + }); + let states = if reverse_order { + vec![states[1].clone(), states[0].clone()] + } else { + states.to_vec() + }; + + Program::new( + vec![Source::new(source, signal([0; 3]))], + vec![Target::finite( + target, + source, + vec![ + TargetCandidateV1::new(first, signal([0; 3])), + TargetCandidateV1::new(second, signal([0xFF; 3])), + ], + )], + ObservationGroup::new(ObservationGroupId::new(9), vec![port]), + vec![], + vec![Paint::Solid { id: paint, target }], + vec![Surface::Input { + id: surface, + input: port, + }], + vec![Occurrence::new( + occurrence, + paint, + surface, + CompositionProfile::EncodedSrgb8SourceOverV1, + context(SurroundProfileId::AverageV1), + )], + ConstraintSet::new( + vec![ConstraintInvocation::hard( + ConstraintId::new(10), + occurrence, + CoreProgramConstraintInvocationV1::ExactSrgb8(Srgb8::new([0; 3])), + )], + vec![], + ), + vec![OutputBinding::new(OutputSlotId::new(11), paint)], + CoreProgramEvaluatorsV1, + ) + .with_joint_selection(DeclaredJointSelectionV1::new(states)) +} + +fn fixed_single_target_program() -> CoreProgramV1 { + let source = SourceId::new(1); + let target = TargetId::new(2); + let paint = PaintId::new(5); + let port = SurfaceInputPortId::new(6); + let surface = SurfaceId::new(7); + let occurrence = OccurrenceId::new(8); + + Program::new( + vec![Source::new(source, signal([0; 3]))], + vec![Target::fixed(target, source)], + ObservationGroup::new(ObservationGroupId::new(9), vec![port]), + vec![], + vec![Paint::Solid { id: paint, target }], + vec![Surface::Input { + id: surface, + input: port, + }], + vec![Occurrence::new( + occurrence, + paint, + surface, + CompositionProfile::EncodedSrgb8SourceOverV1, + context(SurroundProfileId::AverageV1), + )], + ConstraintSet::new( + vec![ConstraintInvocation::hard( + ConstraintId::new(10), + occurrence, + CoreProgramConstraintInvocationV1::ExactSrgb8(Srgb8::new([0; 3])), + )], + vec![], + ), + vec![OutputBinding::new(OutputSlotId::new(11), paint)], + CoreProgramEvaluatorsV1, + ) +} + +#[test] +fn fixed_and_finite_target_domains_have_distinct_identity() { + let fixed = fixed_single_target_program().compile().unwrap(); + let finite = finite_program(false).compile().unwrap(); + + assert_ne!(fixed.content_identity(), finite.content_identity()); +} + +#[test] +fn content_identity_retains_the_explicit_joint_state_order() { + let forward = finite_program(false).compile().unwrap(); + let reversed = finite_program(true).compile().unwrap(); + + assert_ne!(forward.content_identity(), reversed.content_identity()); +} + +#[derive(Clone, Copy)] +enum RegularIncidence { + OneCycle, + TwoCycles, +} + +fn regular_incidence_program(kind: RegularIncidence) -> CoreProgramV1 { + let source = SourceId::new(1); + let target = TargetId::new(2); + let paints = [10, 11, 12, 13].map(PaintId::new); + let ports = [20, 21, 22, 23].map(SurfaceInputPortId::new); + let surfaces = [30, 31, 32, 33].map(SurfaceId::new); + let incidence = match kind { + RegularIncidence::OneCycle => [ + (0, 0), + (0, 1), + (1, 1), + (1, 2), + (2, 2), + (2, 3), + (3, 3), + (3, 0), + ], + RegularIncidence::TwoCycles => [ + (0, 0), + (0, 1), + (1, 0), + (1, 1), + (2, 2), + (2, 3), + (3, 2), + (3, 3), + ], + }; + let occurrences = incidence + .iter() + .enumerate() + .map(|(index, (paint, surface))| { + Occurrence::new( + OccurrenceId::new(40 + index as u32), + paints[*paint], + surfaces[*surface], + CompositionProfile::EncodedSrgb8SourceOverV1, + context(SurroundProfileId::AverageV1), + ) + }) + .collect::>(); + let constraints = occurrences + .iter() + .enumerate() + .map(|(index, occurrence)| { + ConstraintInvocation::hard( + ConstraintId::new(60 + index as u32), + occurrence.id(), + CoreProgramConstraintInvocationV1::ExactSrgb8(Srgb8::new([0x20; 3])), + ) + }) + .collect(); + + Program::new( + vec![Source::new(source, signal([0x20; 3]))], + vec![Target::fixed(target, source)], + ObservationGroup::new(ObservationGroupId::new(3), ports.to_vec()), + vec![], + paints + .iter() + .copied() + .map(|id| Paint::Solid { id, target }) + .collect(), + surfaces + .iter() + .copied() + .zip(ports) + .map(|(id, input)| Surface::Input { id, input }) + .collect(), + occurrences, + ConstraintSet::new(constraints, vec![]), + paints + .iter() + .copied() + .enumerate() + .map(|(index, paint)| OutputBinding::new(OutputSlotId::new(80 + index as u32), paint)) + .collect(), + CoreProgramEvaluatorsV1, + ) +} + +#[test] +fn exact_canon_distinguishes_regular_non_isomorphic_programs() { + let one_cycle = regular_incidence_program(RegularIncidence::OneCycle) + .compile() + .unwrap(); + let two_cycles = regular_incidence_program(RegularIncidence::TwoCycles) + .compile() + .unwrap(); + + assert_ne!(one_cycle.content_identity(), two_cycles.content_identity()); +} diff --git a/crates/labcolors-core/src/program_joint_integration_tests.rs b/crates/labcolors-core/src/program_joint_integration_tests.rs index db1ce5d2..88afd5a1 100644 --- a/crates/labcolors-core/src/program_joint_integration_tests.rs +++ b/crates/labcolors-core/src/program_joint_integration_tests.rs @@ -1152,15 +1152,17 @@ fn equivalent_recompiled_owner_is_a_new_generation_and_cannot_revive_old_session let first_evaluator = CountingProgramWcag22Srgb8V1::default(); let first_calls = first_evaluator.clone(); let mut compiled = counting_fixed_program(first_evaluator); + let first_content_identity = compiled.content_identity(); let mut old_session = compiled.instantiate(STREAM).unwrap(); - assert!(matches!( - old_session.update(update(1, 0x00)).unwrap(), - SessionState::Ready { .. } - )); + let SessionState::Ready { current } = old_session.update(update(1, 0x00)).unwrap() else { + panic!("the first owner must certify its admitted input"); + }; + assert_eq!(current.report().content_identity(), first_content_identity); let replacement_evaluator = CountingProgramWcag22Srgb8V1::default(); let replacement_calls = replacement_evaluator.clone(); compiled = counting_fixed_program(replacement_evaluator); + assert_eq!(compiled.content_identity(), first_content_identity); assert!(matches!( old_session.update(update(2, 0x00)), Err(SessionUpdateError::OwnerExpired), diff --git a/crates/labcolors-core/src/program_session.rs b/crates/labcolors-core/src/program_session.rs index 30c4b217..a755d754 100644 --- a/crates/labcolors-core/src/program_session.rs +++ b/crates/labcolors-core/src/program_session.rs @@ -22,10 +22,11 @@ use crate::appearance::{ }; use crate::composition::CompositionProfileV1; use crate::constraints::{ - Evaluator, ExactSrgb8IdentityV1, HardDecision, ProgramPointAssessmentErrorV1, - ProgramPointEvaluatorV1, ProgramPointInvocation, ProgramPointTargetV1, - ProgramVisiblePointBindingV1, ProgramVisiblePointPassEvidence, - ProgramVisiblePointViolationEvidence, Wcag22Srgb8V1, assess_program_point_hard, + Evaluator, ExactSrgb8IdentityV1, HardDecision, ProgramConstraintContentV1, + ProgramPointAssessmentErrorV1, ProgramPointEvaluatorContentV1, ProgramPointEvaluatorV1, + ProgramPointInvocation, ProgramPointTargetV1, ProgramVisiblePointBindingV1, + ProgramVisiblePointPassEvidence, ProgramVisiblePointViolationEvidence, Wcag22Srgb8V1, + assess_program_point_hard, }; use crate::joint::{ AdmittedFiniteJointOrderV1, FiniteDomainOrdinalV1, FiniteJointOrderErrorV1, @@ -45,6 +46,10 @@ use crate::session::{ }; use crate::wcag22::Wcag22CriterionV1; +#[path = "program_identity.rs"] +mod identity; +pub(crate) use identity::ProgramContentIdentityV1; + /// Opaque identity of one immutable authored colour source. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct SourceId(u32); @@ -483,6 +488,8 @@ pub(crate) trait ProgramConstraintEvaluatorSetV1: Sized { fn pass_binding(evidence: &Self::PassEvidence) -> ProgramVisiblePointBindingV1; fn violation_binding(evidence: &Self::ViolationEvidence) -> ProgramVisiblePointBindingV1; + + fn constraint_content(&self, invocation: Self::Invocation) -> ProgramConstraintContentV1; } impl ProgramConstraintEvaluatorSetV1 for Evaluation @@ -511,6 +518,10 @@ where fn violation_binding(evidence: &Self::ViolationEvidence) -> ProgramVisiblePointBindingV1 { *evidence.binding() } + + fn constraint_content(&self, invocation: Self::Invocation) -> ProgramConstraintContentV1 { + self.program_constraint_content_v1(invocation) + } } /// Generates the code-owned heterogeneous evaluator set as parallel closed @@ -602,6 +613,18 @@ macro_rules! define_core_program_evaluators_v1 { $(CoreProgramViolationEvidenceV1::$variant(evidence) => *evidence.binding()),+ } } + + fn constraint_content(&self, invocation: Self::Invocation) -> ProgramConstraintContentV1 { + match invocation { + $(CoreProgramConstraintInvocationV1::$variant(invocation) => { + let evaluator: $evaluator = $evaluator_value; + <$evaluator as ProgramPointEvaluatorContentV1>::program_constraint_content_v1( + &evaluator, + invocation, + ) + }),+ + } + } } }; } @@ -1022,6 +1045,7 @@ where Evaluation: ProgramConstraintEvaluatorSetV1, ProgramConstraintInvocationOf: Copy, { + content_identity: ProgramContentIdentityV1, evaluator: Evaluation, graph: CompiledAppearanceGraph, binding_template: AdmittedAppearanceBindings, @@ -1061,6 +1085,15 @@ where self.owner_generation.observation_group.id } + /// Контентный адрес Program в границах схемы V1. + /// + /// Opaque ID и порядок неупорядоченных объявлений исключены; явный joint + /// order входит в адрес. Адрес не подтверждает поколение владельца и не + /// заменяет revision-bound evidence. + pub fn content_identity(&self) -> ProgramContentIdentityV1 { + self.owner_generation.content_identity + } + pub fn surface_input_ports(&self) -> &[SurfaceInputPortId] { self.owner_generation.observation_group.schema.as_slice() } @@ -1204,11 +1237,14 @@ where } } -/// Complete revision-bound assessment in case-major, constraint-ID order. +/// Полная оценка, привязанная к revision. Для selected/fixed результата ячейки +/// идут сначала по physical case, затем по constraint ID. Exhaustive conflict +/// дополнительно упорядочен сначала по joint state. pub struct ProgramReportV1 where Evaluation: ProgramConstraintEvaluatorSetV1, { + content_identity: ProgramContentIdentityV1, observation: RevisionBoundObservationV1, cells: Vec>, } @@ -1217,6 +1253,12 @@ impl ProgramReportV1 where Evaluation: ProgramConstraintEvaluatorSetV1, { + /// Адрес содержимого Program, по которому построен report; это не + /// идентификатор поколения и не runtime-authority. + pub const fn content_identity(&self) -> ProgramContentIdentityV1 { + self.content_identity + } + pub const fn observation(&self) -> &RevisionBoundObservationV1 { &self.observation } @@ -1673,6 +1715,7 @@ where Ok(SessionDecision::Violation(ProgramConflictV1 { report: ProgramReportV1 { + content_identity: epoch.content_identity, observation, cells: buffers.conflict_cells, }, @@ -1740,7 +1783,11 @@ where if cells.len() != expected_cell_count { return Err(ProgramSessionEvaluationError::InternalInvariant); } - let report = ProgramReportV1 { observation, cells }; + let report = ProgramReportV1 { + content_identity: epoch.content_identity, + observation, + cells, + }; if has_hard_violation { Ok(SessionDecision::Violation(ProgramConflictV1 { report, @@ -2036,15 +2083,20 @@ where .map_err(map_observation_schema_compile_error)?; validate_terminal_dependency_cone(&program)?; - let (finite_targets, joint_selection) = - compile_targets(&graph, program.targets, program.joint_selection)?; + let (finite_targets, joint_selection) = compile_targets( + &graph, + &mut program.targets, + program.joint_selection.as_mut(), + )?; let all_occurrence_contexts = compile_occurrence_contexts(&graph, &program.occurrences)?; let mut constraints = - compile_constraints::(&graph, &all_occurrence_contexts, program.constraints)?; + compile_constraints::(&graph, &all_occurrence_contexts, &program.constraints)?; let occurrence_contexts = compact_constraint_contexts(&all_occurrence_contexts, &mut constraints)?; - let outputs = compile_outputs(&graph, program.outputs)?; + let outputs = compile_outputs(&graph, &mut program.outputs)?; + let content_identity = identity::compile_program_content_identity_v1(&program)?; Ok(ProgramEpochV1 { + content_identity, evaluator: program.evaluator, graph, binding_template, @@ -2489,8 +2541,8 @@ struct LoweredConstraint { fn compile_targets( graph: &CompiledAppearanceGraph, - authored_targets: Vec, - authored_selection: Option, + authored_targets: &mut [Target], + authored_selection: Option<&mut DeclaredJointSelectionV1>, ) -> Result< ( Box<[CompiledFiniteTargetV1]>, @@ -2498,10 +2550,10 @@ fn compile_targets( ), ProgramCompileError, > { - struct CanonicalFiniteTargetV1 { + struct CanonicalFiniteTargetV1<'a> { id: TargetId, binding: CompiledColorInputSlotV1, - candidates: Vec, + candidates: &'a [TargetCandidateV1], } let mut compiled = Vec::new(); @@ -2509,7 +2561,7 @@ fn compile_targets( .try_reserve_exact(authored_targets.len()) .map_err(|_| ProgramCompileError::ResourceExhausted)?; for target in authored_targets { - let TargetDomainV1::Finite(mut candidates) = target.domain else { + let TargetDomainV1::Finite(candidates) = &mut target.domain else { continue; }; if candidates.is_empty() { @@ -2568,9 +2620,11 @@ fn compile_targets( authored_tuples .try_reserve_exact(authored_selection.states.len()) .map_err(|_| ProgramCompileError::ResourceExhausted)?; - for (state_index, mut state) in authored_selection.states.into_iter().enumerate() { - state.choices.sort_unstable_by_key(|choice| choice.target); - if let Some(target) = state + for (state_index, authored_state) in authored_selection.states.iter_mut().enumerate() { + authored_state + .choices + .sort_unstable_by_key(|choice| choice.target); + if let Some(target) = authored_state .choices .windows(2) .find(|pair| pair[0].target == pair[1].target) @@ -2581,7 +2635,7 @@ fn compile_targets( target, }); } - if let Some(choice) = state.choices.iter().find(|choice| { + if let Some(choice) = authored_state.choices.iter().find(|choice| { compiled .binary_search_by_key(&choice.target, |target| target.id) .is_err() @@ -2597,14 +2651,14 @@ fn compile_targets( .try_reserve_exact(compiled.len()) .map_err(|_| ProgramCompileError::ResourceExhausted)?; for target in &compiled { - let choice_index = state + let choice_index = authored_state .choices .binary_search_by_key(&target.id, |choice| choice.target) .map_err(|_| ProgramCompileError::JointStateMissingTarget { state: state_index, target: target.id, })?; - let choice = state.choices[choice_index]; + let choice = authored_state.choices[choice_index]; let candidate_index = target .candidates .binary_search_by_key(&choice.candidate, |candidate| candidate.id) @@ -2634,7 +2688,7 @@ fn compile_targets( candidates .try_reserve_exact(target.candidates.len()) .map_err(|_| ProgramCompileError::ResourceExhausted)?; - candidates.extend(target.candidates.into_iter().map(TargetCandidateV1::signal)); + candidates.extend(target.candidates.iter().map(|candidate| candidate.signal())); runtime_targets.push(CompiledFiniteTargetV1 { binding: target.binding, candidates: candidates.into_boxed_slice(), @@ -2687,7 +2741,7 @@ fn compile_occurrence_contexts( fn compile_constraints( graph: &CompiledAppearanceGraph, occurrence_contexts: &[CompiledOccurrenceContextV1], - authored: ConstraintSet>, + authored: &ConstraintSet>, ) -> Result< Box<[CompiledPointConstraint>]>, ProgramCompileError, @@ -2705,21 +2759,16 @@ where 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.hard.iter().map(|constraint| LoweredConstraint { + id: constraint.id, + target: constraint.target, + mode: CompiledConstraintModeV1::Hard, + invocation: constraint.invocation, + })); lowered.extend( authored .report_only - .into_iter() + .iter() .map(|constraint| LoweredConstraint { id: constraint.id, target: constraint.target, @@ -2809,10 +2858,9 @@ fn compact_constraint_contexts( fn compile_outputs( graph: &CompiledAppearanceGraph, - authored: Vec, + authored: &mut [OutputBinding], ) -> 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) @@ -2821,7 +2869,7 @@ fn compile_outputs( { return Err(ProgramCompileError::DuplicateOutputSlot { output: duplicate }); } - for output in &authored { + for output in authored.iter() { if graph.bind_paint(output.paint).is_none() { return Err(ProgramCompileError::MissingOutputPaint { output: output.output, @@ -2834,7 +2882,7 @@ fn compile_outputs( compiled .try_reserve_exact(len) .map_err(|_| ProgramCompileError::ResourceExhausted)?; - for output in authored { + for output in authored.iter().copied() { let paint = graph .bind_paint(output.paint) .ok_or(ProgramCompileError::InternalInvariant)?; diff --git a/crates/labcolors-core/src/sha256.rs b/crates/labcolors-core/src/sha256.rs index 45b538a9..537760b9 100644 --- a/crates/labcolors-core/src/sha256.rs +++ b/crates/labcolors-core/src/sha256.rs @@ -1,21 +1,20 @@ -//! Dependency-free SHA-256 used only for canonical content identities. +//! SHA-256 без зависимостей только для канонических контентных адресов. //! -//! Constants and operations are the SHA-256 algorithm specified by NIST -//! FIPS 180-4, section 6.2. This module is intentionally private: callers use -//! domain-specific digest types instead of treating a hash as mathematical -//! proof or a semantic identifier. +//! Константы и операции следуют алгоритму NIST FIPS 180-4, раздел 6.2. Модуль +//! намеренно закрыт: вызывающий код использует предметные типы адресов и не +//! выдаёт хеш за математическое доказательство либо семантический ID. -/// Exact 256-bit SHA-256 output. +/// Точный 256-битный результат SHA-256. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub(crate) struct Digest([u8; 32]); impl Digest { - /// Borrow the exact digest bytes. + /// Заимствует точные байты digest-а. pub(crate) const fn as_bytes(&self) -> &[u8; 32] { &self.0 } - /// Canonical lowercase hexadecimal encoding. + /// Каноническая hexadecimal-запись в нижнем регистре. #[cfg(test)] #[allow(dead_code)] pub(crate) fn to_hex(self) -> String { @@ -107,11 +106,10 @@ const ROUND_CONSTANTS: [u32; 64] = [ 0xc671_78f2, ]; -/// Incremental SHA-256 state with one fixed-size pending block. +/// Инкрементальное состояние SHA-256 с одним pending-блоком фиксированного размера. /// -/// The byte count wraps modulo 2^64, matching the encoded message-length field -/// defined by FIPS 180-4. No input bytes are retained after their block has -/// been compressed. +/// Счётчик байтов оборачивается по модулю 2^64, как поле длины сообщения в +/// FIPS 180-4. После сжатия блока входные байты не сохраняются. pub(crate) struct Hasher { state: [u32; 8], pending: [u8; 64], @@ -120,7 +118,7 @@ pub(crate) struct Hasher { } impl Hasher { - /// Start a new SHA-256 computation. + /// Начинает новое вычисление SHA-256. pub(crate) const fn new() -> Self { Self { state: INITIAL_STATE, @@ -130,8 +128,7 @@ impl Hasher { } } - /// Add bytes to this computation without allocating or retaining the - /// caller's slice. + /// Добавляет байты без аллокации и сохранения среза вызывающей стороны. pub(crate) fn update(&mut self, mut bytes: &[u8]) { self.byte_len = self.byte_len.wrapping_add(bytes.len() as u64); @@ -163,7 +160,7 @@ impl Hasher { self.pending_len = remainder.len(); } - /// Finish the computation and return its exact 256-bit output. + /// Завершает вычисление и возвращает точный 256-битный результат. pub(crate) fn finalize(mut self) -> Digest { let mut final_block = self.pending; final_block[self.pending_len] = 0x80; @@ -188,7 +185,7 @@ impl Hasher { } } -/// Hash one byte slice without heap-allocating a padded copy. +/// Хеширует один байтовый срез без padded-копии в heap. pub(crate) fn digest(bytes: &[u8]) -> Digest { let mut hasher = Hasher::new(); hasher.update(bytes); diff --git a/packages/colors/bench/wasm.json b/packages/colors/bench/wasm.json index f88f6209..43d7b1ea 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-29971399220", + "source": "github-actions-run-30115821523", "platform": "linux-x64", - "rawBytes": 376830 + "rawBytes": 376985 }, "policy": { - "maxRawBytes": 376830, - "basis": "canonical-authored-program-lowerer", + "maxRawBytes": 376985, + "basis": "program-content-identity", "gzip": "diagnostic-only" } } diff --git a/scripts/check-wasm-size-budget.mjs b/scripts/check-wasm-size-budget.mjs index a5776086..b41b24f6 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 = - "84a8d2e1f0517f871256fdc64bdf9323135c497e3a374b751d8c0bb710f60084"; + "73722a93248ba005c6b8cf1846e52c1344dcb9f85a3a2133a25c51dacbefdcfc"; const SCHEMA_VERSION = 1; const CANONICAL_ARTIFACT = "packages/colors/pkg/labcolors_bg.wasm"; diff --git a/scripts/verify_point_support_surplus.py b/scripts/verify_point_support_surplus.py index e39328fa..608c6cdb 100755 --- a/scripts/verify_point_support_surplus.py +++ b/scripts/verify_point_support_surplus.py @@ -58,7 +58,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 = ( - "4310a239e732f0710fd6201c2517fd98ef4afd0e3cd8e27c248dee69d6c54cb3" + "246c77dbf4923aca4cdaedb12be910ceb2885c94e4e42f652cdcba9b9417a427" ) EXPECTED_Q55_PROOF_SHA256 = ( "ac59cf89503170c789223b91d775213a19d4e571ef930f2ea609fcd51b14defd"